Merge "Add action plugins to restrict untrusted execution" into feature/zuulv3
diff --git a/tests/base.py b/tests/base.py
index 41fa29f..59cc9ae 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -757,11 +757,12 @@
         self.launcher_server.lock.release()
         return result
 
-    def runAnsible(self, cmd, timeout):
+    def runAnsible(self, cmd, timeout, secure=False):
         build = self.launcher_server.job_builds[self.job.unique]
 
         if self.launcher_server._run_ansible:
-            result = super(RecordingAnsibleJob, self).runAnsible(cmd, timeout)
+            result = super(RecordingAnsibleJob, self).runAnsible(
+                cmd, timeout, secure=secure)
         else:
             result = build.run()
         return result
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
index 6b0af99..408810e 100644
--- a/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/python27.yaml
@@ -3,3 +3,6 @@
     - file:
         path: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
         state: touch
+    - copy:
+        src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
+        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.copied"
diff --git a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
index 6bedb07..6abfc47 100644
--- a/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/ansible/git/org_project/.zuul.yaml
@@ -1,6 +1,11 @@
+- job:
+    parent: python27
+    name: faillocal
+
 - project:
     name: org/project
 
     check:
       jobs:
         - python27
+        - faillocal
diff --git a/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml b/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml
new file mode 100644
index 0000000..6689e18
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/org_project/playbooks/faillocal.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - copy:
+        src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
+        dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.failed"
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 0ba5ff8..74d69c9 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -125,10 +125,18 @@
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
+        build = self.getJobFromHistory('faillocal')
+        self.assertEqual(build.result, 'FAILURE')
         build = self.getJobFromHistory('python27')
         self.assertEqual(build.result, 'SUCCESS')
         flag_path = os.path.join(self.test_root, build.uuid + '.flag')
         self.assertTrue(os.path.exists(flag_path))
+        copied_path = os.path.join(self.test_root, build.uuid +
+                                   '.copied')
+        self.assertTrue(os.path.exists(copied_path))
+        failed_path = os.path.join(self.test_root, build.uuid +
+                                   '.failed')
+        self.assertFalse(os.path.exists(failed_path))
         pre_flag_path = os.path.join(self.test_root, build.uuid +
                                      '.pre.flag')
         self.assertTrue(os.path.exists(pre_flag_path))
diff --git a/zuul/ansible/action/__init__.py b/zuul/ansible/action/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/zuul/ansible/action/__init__.py
diff --git a/zuul/ansible/action/add_host.py b/zuul/ansible/action/add_host.py
new file mode 100644
index 0000000..e41e4e1
--- /dev/null
+++ b/zuul/ansible/action/add_host.py
@@ -0,0 +1,25 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+from zuul.ansible.plugins.action import add_host
+
+
+class ActionModule(add_host.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        return dict(
+            failed=True,
+            msg="Adding hosts to the inventory is prohibited")
diff --git a/zuul/ansible/action/asa_config.py b/zuul/ansible/action/asa_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/asa_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/asa_template.py b/zuul/ansible/action/asa_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/asa_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/assemble.py b/zuul/ansible/action/assemble.py
new file mode 100644
index 0000000..d0bff37
--- /dev/null
+++ b/zuul/ansible/action/assemble.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import assemble
+
+
+class ActionModule(assemble.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/copy.py b/zuul/ansible/action/copy.py
new file mode 100644
index 0000000..5dc9fa8
--- /dev/null
+++ b/zuul/ansible/action/copy.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import copy
+
+
+class ActionModule(copy.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/dellos10_config.py b/zuul/ansible/action/dellos10_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/dellos10_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/dellos6_config.py b/zuul/ansible/action/dellos6_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/dellos6_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/dellos9_config.py b/zuul/ansible/action/dellos9_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/dellos9_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/eos_config.py b/zuul/ansible/action/eos_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/eos_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/eos_template.py b/zuul/ansible/action/eos_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/eos_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/fetch.py b/zuul/ansible/action/fetch.py
new file mode 100644
index 0000000..fe06c3b
--- /dev/null
+++ b/zuul/ansible/action/fetch.py
@@ -0,0 +1,29 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import fetch
+
+
+class ActionModule(fetch.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        dest = self._task.args.get('dest', None)
+
+        if dest and not paths._is_safe_path(dest):
+            return paths._fail_dict(dest)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/include_vars.py b/zuul/ansible/action/include_vars.py
new file mode 100644
index 0000000..aa0e7d8
--- /dev/null
+++ b/zuul/ansible/action/include_vars.py
@@ -0,0 +1,31 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import include_vars
+
+
+class ActionModule(include_vars.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source_dir = self._task.args.get('dir', None)
+        source_file = self._task.args.get('file', None)
+
+        for fileloc in (source_dir, source_file):
+            if fileloc and not paths._is_safe_path(fileloc):
+                return paths._fail_dict(fileloc)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/ios_config.py b/zuul/ansible/action/ios_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/ios_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/ios_template.py b/zuul/ansible/action/ios_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/ios_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/iosxr_config.py b/zuul/ansible/action/iosxr_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/iosxr_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/iosxr_template.py b/zuul/ansible/action/iosxr_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/iosxr_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/junos_config.py b/zuul/ansible/action/junos_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/junos_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/junos_template.py b/zuul/ansible/action/junos_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/junos_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/net_config.py b/zuul/ansible/action/net_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/net_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/net_template.py b/zuul/ansible/action/net_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/net_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/network.py b/zuul/ansible/action/network.py
new file mode 100644
index 0000000..31a8739
--- /dev/null
+++ b/zuul/ansible/action/network.py
@@ -0,0 +1,24 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible.plugins.action import network
+
+
+class ActionModule(network.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        return dict(failed=True, msg='Use of network modules is prohibited')
diff --git a/zuul/ansible/action/normal.py b/zuul/ansible/action/normal.py
new file mode 100644
index 0000000..d4b2396
--- /dev/null
+++ b/zuul/ansible/action/normal.py
@@ -0,0 +1,33 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible.plugins.action import normal
+
+
+class ActionModule(normal.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        if (self._play_context.connection == 'local'
+                or self._play_context.remote_addr == 'localhost'
+                or self._play_context.remote_addr.startswith('127.')
+                or self._task.delegate_to == 'localhost'
+                or (self._task.delegate_to
+                    and self._task.delegate_to.startswtih('127.'))):
+            return dict(
+                failed=True,
+                msg="Executing local code is prohibited")
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/nxos_config.py b/zuul/ansible/action/nxos_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/nxos_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/nxos_template.py b/zuul/ansible/action/nxos_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/nxos_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/ops_config.py b/zuul/ansible/action/ops_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/ops_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/ops_template.py b/zuul/ansible/action/ops_template.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/ops_template.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/patch.py b/zuul/ansible/action/patch.py
new file mode 100644
index 0000000..d630844
--- /dev/null
+++ b/zuul/ansible/action/patch.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import patch
+
+
+class ActionModule(patch.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/script.py b/zuul/ansible/action/script.py
new file mode 100644
index 0000000..bd3d5d5
--- /dev/null
+++ b/zuul/ansible/action/script.py
@@ -0,0 +1,34 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import copy
+
+
+class ActionModule(copy.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        # the script name is the first item in the raw params, so we split it
+        # out now so we know the file name we need to transfer to the remote,
+        # and everything else is an argument to the script which we need later
+        # to append to the remote command
+        parts = self._task.args.get('_raw_params', '').strip().split()
+        source = parts[0]
+
+        if not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/sros_config.py b/zuul/ansible/action/sros_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/sros_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/synchronize.py b/zuul/ansible/action/synchronize.py
new file mode 100644
index 0000000..cbb7ea2
--- /dev/null
+++ b/zuul/ansible/action/synchronize.py
@@ -0,0 +1,33 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import synchronize
+
+
+class ActionModule(synchronize.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        dest = self._task.args.get('dest', None)
+        pull = self._task.args.get('pull', False)
+
+        if not pull and not paths._is_safe_path(source):
+            return paths._fail_dict(source, prefix='Syncing files from')
+        if pull and not paths._is_safe_path(dest):
+            return paths._fail_dict(dest, prefix='Syncing files to')
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/template.py b/zuul/ansible/action/template.py
new file mode 100644
index 0000000..96471ae
--- /dev/null
+++ b/zuul/ansible/action/template.py
@@ -0,0 +1,29 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import template
+
+
+class ActionModule(template.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+
+        if not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/unarchive.py b/zuul/ansible/action/unarchive.py
new file mode 100644
index 0000000..c3f6e91
--- /dev/null
+++ b/zuul/ansible/action/unarchive.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import unarchive
+
+
+class ActionModule(unarchive.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/vyos_config.py b/zuul/ansible/action/vyos_config.py
new file mode 120000
index 0000000..7a739ba
--- /dev/null
+++ b/zuul/ansible/action/vyos_config.py
@@ -0,0 +1 @@
+network.py
\ No newline at end of file
diff --git a/zuul/ansible/action/win_copy.py b/zuul/ansible/action/win_copy.py
new file mode 100644
index 0000000..eef3a1c
--- /dev/null
+++ b/zuul/ansible/action/win_copy.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import win_copy
+
+
+class ActionModule(win_copy.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/action/win_template.py b/zuul/ansible/action/win_template.py
new file mode 100644
index 0000000..2a47216
--- /dev/null
+++ b/zuul/ansible/action/win_template.py
@@ -0,0 +1,30 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from zuul.ansible import paths
+from zuul.ansible.plugins.action import win_template
+
+
+class ActionModule(win_template.ActionModule):
+
+    def run(self, tmp=None, task_vars=None):
+
+        source = self._task.args.get('src', None)
+        remote_src = self._task.args.get('remote_src', False)
+
+        if not remote_src and not paths._is_safe_path(source):
+            return paths._fail_dict(source)
+        return super(ActionModule, self).run(tmp, task_vars)
diff --git a/zuul/ansible/paths.py b/zuul/ansible/paths.py
new file mode 100644
index 0000000..2bd0181
--- /dev/null
+++ b/zuul/ansible/paths.py
@@ -0,0 +1,33 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# This module is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This software is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this software.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+
+def _is_safe_path(path):
+    if os.path.isabs(path):
+        return False
+    if not os.path.abspath(os.path.expanduser(path)).startswith(
+            os.path.abspath(os.path.curdir)):
+        return False
+    return True
+
+
+def _fail_dict(path, prefix='Accessing files from'):
+    return dict(
+        failed=True,
+        path=path,
+        msg="{prefix} outside the working dir is prohibited".format(
+            prefix=prefix))
diff --git a/zuul/launcher/server.py b/zuul/launcher/server.py
index df71cc9..ce0ced6 100644
--- a/zuul/launcher/server.py
+++ b/zuul/launcher/server.py
@@ -29,6 +29,7 @@
 import gear
 
 import zuul.merger.merger
+import zuul.ansible.action
 import zuul.ansible.library
 from zuul.lib import commandsocket
 
@@ -85,6 +86,8 @@
         os.makedirs(self.git_root)
         self.ansible_root = os.path.join(self.root, 'ansible')
         os.makedirs(self.ansible_root)
+        self.secure_ansible_root = os.path.join(self.ansible_root, 'secure')
+        os.makedirs(self.secure_ansible_root)
         self.known_hosts = os.path.join(self.ansible_root, 'known_hosts')
         self.inventory = os.path.join(self.ansible_root, 'inventory')
         self.vars = os.path.join(self.ansible_root, 'vars.yaml')
@@ -93,6 +96,8 @@
         self.pre_playbooks = []
         self.post_playbooks = []
         self.config = os.path.join(self.ansible_root, 'ansible.cfg')
+        self.secure_config = os.path.join(
+            self.secure_ansible_root, 'ansible.cfg')
         self.ansible_log = os.path.join(self.ansible_root, 'ansible_log.txt')
 
     def addPrePlaybook(self):
@@ -238,11 +243,18 @@
         self.library_dir = os.path.join(ansible_dir, 'library')
         if not os.path.exists(self.library_dir):
             os.makedirs(self.library_dir)
+        self.action_dir = os.path.join(ansible_dir, 'action')
+        if not os.path.exists(self.action_dir):
+            os.makedirs(self.action_dir)
 
         library_path = os.path.dirname(os.path.abspath(
             zuul.ansible.library.__file__))
         for fn in os.listdir(library_path):
             shutil.copy(os.path.join(library_path, fn), self.library_dir)
+        action_path = os.path.dirname(os.path.abspath(
+            zuul.ansible.action.__file__))
+        for fn in os.listdir(action_path):
+            shutil.copy(os.path.join(action_path, fn), self.action_dir)
 
         self.job_workers = {}
 
@@ -580,10 +592,28 @@
             hosts.append((node['name'], dict(ansible_connection='local')))
         return hosts
 
-    def findPlaybook(self, path, required=False):
+    def _blockPluginDirs(self, fn):
+        '''Prevent execution of playbooks with plugins
+
+        Plugins are loaded from roles and also if there is a plugin dir
+        adjacent to the playbook. Role exclusion will be handled elsewhere,
+        but while we're looking for playbooks, throw an error if the playbook
+        exists in a location that would cause a plugin to get loaded if the
+        playbook is not in a secure repository.
+        '''
+        playbook_dir = os.path.dirname(os.path.abspath(fn))
+        for entry in os.listdir(playbook_dir):
+            if os.path.isdir(entry) and entry.endswith('_plugins'):
+                raise Exception(
+                    "Ansible plugin dir %s found adjacent to playbook %s in"
+                    " non-secure repo." % (entry, fn))
+
+    def findPlaybook(self, path, required=False, secure=False):
         for ext in ['.yaml', '.yml']:
             fn = path + ext
             if os.path.exists(fn):
+                if not secure:
+                    self._blockPluginDirs(fn)
                 return fn
         if required:
             raise Exception("Unable to find playbook %s" % path)
@@ -631,7 +661,10 @@
                     path = os.path.join(self.jobdir.git_root,
                                         project.name,
                                         playbook['path'])
-                    jobdir_playbook.path = self.findPlaybook(path, main)
+                    jobdir_playbook.path = self.findPlaybook(
+                        path,
+                        required=main,
+                        secure=playbook['secure'])
                     return
         # The playbook repo is either a config repo, or it isn't in
         # the stack of changes we are testing, so check out the branch
@@ -643,7 +676,10 @@
         path = os.path.join(jobdir_playbook.root,
                             project.name,
                             playbook['path'])
-        jobdir_playbook.path = self.findPlaybook(path, main)
+        jobdir_playbook.path = self.findPlaybook(
+            path,
+            required=main,
+            secure=playbook['secure'])
 
     def prepareAnsibleFiles(self, args):
         with open(self.jobdir.inventory, 'w') as inventory:
@@ -657,7 +693,11 @@
             zuul_vars = dict(zuul=args['zuul'])
             vars_yaml.write(
                 yaml.safe_dump(zuul_vars, default_flow_style=False))
-        with open(self.jobdir.config, 'w') as config:
+        self.writeAnsibleConfig(self.jobdir.config)
+        self.writeAnsibleConfig(self.jobdir.secure_config, secure=True)
+
+    def writeAnsibleConfig(self, config_path, secure=False):
+        with open(config_path, 'w') as config:
             config.write('[defaults]\n')
             config.write('hostfile = %s\n' % self.jobdir.inventory)
             config.write('local_tmp = %s/.ansible/local_tmp\n' %
@@ -673,6 +713,9 @@
             # bump the timeout because busy nodes may take more than
             # 10s to respond
             config.write('timeout = 30\n')
+            if not secure:
+                config.write('action_plugins = %s\n'
+                             % self.launcher_server.action_dir)
 
             config.write('[ssh_connection]\n')
             # NB: when setting pipelining = True, keep_remote_files
@@ -706,17 +749,22 @@
                 self.log.exception("Exception while killing "
                                    "ansible process:")
 
-    def runAnsible(self, cmd, timeout):
+    def runAnsible(self, cmd, timeout, secure=False):
         env_copy = os.environ.copy()
         env_copy['LOGNAME'] = 'zuul'
 
+        if secure:
+            cwd = self.jobdir.secure_ansible_root
+        else:
+            cwd = self.jobdir.ansible_root
+
         with self.proc_lock:
             if self.aborted:
                 return (self.RESULT_ABORTED, None)
             self.log.debug("Ansible command: %s" % (cmd,))
             self.proc = subprocess.Popen(
                 cmd,
-                cwd=self.jobdir.ansible_root,
+                cwd=cwd,
                 stdout=subprocess.PIPE,
                 stderr=subprocess.STDOUT,
                 preexec_fn=os.setsid,
@@ -771,4 +819,5 @@
         # TODOv3: get this from the job
         timeout = 60
 
-        return self.runAnsible(cmd, timeout)
+        return self.runAnsible(
+            cmd=cmd, timeout=timeout, secure=playbook.secure)