Repository: puppetlabs/wash
Pull request #239 (2019-05-08 17:46:26)

Support predicate expressions in the `meta` primary
This commit adds support for predicate expressions to the `meta`
primary. Predicate expressions allow users to create more expressive
and powerful predicates. For example, the changes here reduce the
AWS reaper case from the verbose “-m .Tags[?].key termination_date -a -m .Tags[?].value +{1h}”
to the more expressive “-m .Tags[?] .key termination_date -a .value +{1h}”.
See the test cases in primary/meta_test.go for more examples.

Signed-off-by: Enis Inan <enis.inan@puppet.com>

Contributions to this project require sign-off consistent with the [Developers Certificate of Origin](https://developercertificate.org). This can be as simple as using `git commit -s` on each commit.


Patch:
diff --git a/.travis.yml b/.travis.yml
index 60cf306..358535d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,7 +18,7 @@ jobs:
     script: golangci-lint run -v
   - stage: deploy
     name: Deploy to GitHub Releases
-    if: tag IS present
+    if: branch = master AND type = push
     env: LINKFLAGS="-w -s -X github.com/puppetlabs/wash/cmd.version=${TRAVIS_TAG}" CGO_ENABLED=0
     script:
     - GOOS=darwin GOARCH=amd64 go build -ldflags="$LINKFLAGS"
diff --git a/README.md b/README.md
index 6a37acc..7f2fcee 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 
 `wash` helps you deal with all your remote or cloud-native infrastructure using the UNIX-y patterns and tools you already know and love!
 
-[![asciicast](https://asciinema.org/a/245046.svg)](https://asciinema.org/a/245046?cols=120&rows=30)
+_TBD: Screencast_
 
 Exploring, understanding, and inspecting modern infrastructure should be simple and straightforward. Whether it's containers, VMs, network devices, IoT stuff, or anything in between...they all have different ways of enumerating what you have, getting a stream of output, running commands, etc. Every vendor has its own tools and APIs that expose these features, each one different, each one bespoke. Thus, they are difficult to compose together to solve higher-level problems. And that's no fun at all!
 
@@ -61,11 +61,6 @@ We've implemented some neat features inside of `wash` to support the above goals
         - uses `AWS_SHARED_CREDENTIALS_FILE` environment variable or `$HOME/.aws/credentials` and `AWS_CONFIG_FILE` environment variable or `$HOME/.aws/config` to find profiles and configure the SDK
         - IAM roles are supported when configured as described [here](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html). Note that currently `region` will also need to be specified with the profile.
         - if using MFA, `wash` will prompt for it on standard input. Credentials are valid for 1 hour. They are cached under `wash/aws-credentials` in your [user cache directory](#user-cache-directory) so they can be re-used across server restarts. `wash` may have to re-prompt for a new MFA token in response to navigating the `wash` environment to authorize a new session.
-        - SSH is used to connect to EC2 instances. It relies on an `ssh-agent` for credentials, your SSH config to override the default user (`ec2-user`) and port (`22`), and `~/.ssh/known_hosts`. If you don't want to add the target to your `known_hosts` file, you can disable strict host key checking in your SSH config with
-          ```
-          Host *.compute.amazonaws.com
-            StrictHostKeyChecking no
-          ```
 
 * [External plugins](https://github.com/puppetlabs/wash/tree/master/docs/external_plugins)
     * `wash` allows for easy creation of out-of-process plugins using any language you want, from `bash` to `go` or anything in-between!
diff --git a/api/exec.go b/api/exec.go
index ec3c908..9fc32ad 100644
--- a/api/exec.go
+++ b/api/exec.go
@@ -118,14 +118,6 @@ var execHandler handler = func(w http.ResponseWriter, r *http.Request) *errorRes
 		return erroredActionResponse(path, plugin.ExecAction(), err.Error())
 	}
 
-	// Setup context cancellation handling
-	if result.CancelFunc != nil {
-		go func() {
-			<-ctx.Done()
-			result.CancelFunc()
-		}()
-	}
-
 	w.WriteHeader(http.StatusOK)
 	fw.Flush()
 
diff --git a/cmd/internal/find/parser/expression/parser.go b/cmd/internal/find/parser/expression/parser.go
index aae39dd..12cb395 100644
--- a/cmd/internal/find/parser/expression/parser.go
+++ b/cmd/internal/find/parser/expression/parser.go
@@ -64,7 +64,7 @@ func NewParser(predicateParser predicate.Parser) *Parser {
 		}
 	}
 	p.atom = &predicate.CompositeParser{
-		ErrMsg: "expected an atom",
+		MatchErrMsg: "expected an atom",
 		Parsers: []predicate.Parser{
 			notOpParser(p),
 			parensOpParser(p),
@@ -183,15 +183,22 @@ func (parser *Parser) Parse(tokens []string) (predicate.Predicate, []string, err
 	}
 	if _, ok := parser.stack.Peek().(*BinaryOp); ok {
 		// This codepath is possible via something like "p1 -and" or "p1 -and <unknown_token>"
-		if err != nil {
-			// We have "p1 -and <unknown_token>"
-			return nil, nil, err
+		if err == nil {
+			// We have "p1 -and"
+			return nil, nil, fmt.Errorf(
+				"%v: no expression after %v",
+				parser.stack.mostRecentOpToken,
+				parser.stack.mostRecentOpToken,
+			)
 		}
-		return nil, nil, fmt.Errorf(
-			"%v: no expression after %v",
-			parser.stack.mostRecentOpToken,
-			parser.stack.mostRecentOpToken,
-		)
+		// We have "p1 -and <unknown_token>". Pop the binary op off the stack and include
+		// it as part of the remaining tokens. This is useful in case our expression is inside
+		// another expression, where the top-level expression handles combining our parsed
+		// predicate p with whatever's parsed by the "<unknown_token>" bit. For example, it
+		// ensures that the top-level `wash find` parser correctly parses something like
+		// "-m .key foo -o -m .key bar" as "Meta(.key, foo) -o Meta(.key, bar)".
+		parser.stack.Pop()
+		tokens = append([]string{parser.stack.mostRecentOpToken}, tokens...)
 	}
 	// Call s.evaluate() to handle cases like "p1 -and p2"
 	parser.stack.evaluate()
diff --git a/cmd/internal/find/parser/predicate/parser.go b/cmd/internal/find/parser/predicate/parser.go
index 9589a66..fce32fd 100644
--- a/cmd/internal/find/parser/predicate/parser.go
+++ b/cmd/internal/find/parser/predicate/parser.go
@@ -10,7 +10,7 @@ type Parser interface {
 // CompositeParser represents a parser composed of multiple predicate
 // parsers.
 type CompositeParser struct {
-	ErrMsg string
+	MatchErrMsg string
 	Parsers []Parser
 }
 
@@ -24,7 +24,7 @@ func (cp CompositeParser) Parse(tokens []string) (Predicate, []string, error) {
 // ParseAndReturnParserID attempts to parse a predicate from the given tokens. It loops
 // through each of cp's parsers, returning the result of the first parser that matches
 // the input, and the matching parser's ID. If no parser matches the input, then Parse
-// returns a MatchError containing cp.ErrMsg
+// returns a MatchError containing cp.MatchErrMsg
 func (cp CompositeParser) ParseAndReturnParserID(tokens []string) (Predicate, []string, int, error) {
 	for i, parser := range cp.Parsers {
 		p, tokens, err := parser.Parse(tokens)
@@ -38,7 +38,7 @@ func (cp CompositeParser) ParseAndReturnParserID(tokens []string) (Predicate, []
 		}
 	}
 	// None of the parsers matched the input, so return a MatchError
-	return nil, nil, -1, errz.NewMatchError(cp.ErrMsg)		
+	return nil, nil, -1, errz.NewMatchError(cp.MatchErrMsg)		
 }
 
 // ToParser converts the given parse function to a predicate.Parser object
diff --git a/cmd/internal/find/primary/meta.go b/cmd/internal/find/primary/meta.go
index 679a02c..193562a 100644
--- a/cmd/internal/find/primary/meta.go
+++ b/cmd/internal/find/primary/meta.go
@@ -3,40 +3,52 @@ package primary
 import "github.com/puppetlabs/wash/cmd/internal/find/primary/meta"
 
 /*
-metaPrimary        => (-meta|-m) ObjectPredicate
-ObjectPredicate    => EmptyPredicate | ‘.’ Key Predicate
-EmptyPredicate     => -empty
-Key                => [ ^.[ ] ]+ (i.e. one or more cs that aren't ".", "[", or "]")
+metaPrimary         => (-meta|-m) Expression
 
-Predicate          => ObjectPredicate     |
-                      ArrayPredicate      |
-                      PrimitivePredicate
+Expression          => EmptyPredicate | KeySequence PredicateExpression
+EmptyPredicate      => -empty
 
-ArrayPredicate     => EmptyPredicate      |
-                      ‘[' ? ‘]’ Predicate |
-                      ‘[' * ‘]’ Predicate |
-                      ‘[' N ‘]’ Predicate |
+KeySequence         => '.' Key Tail
+Key                 => [ ^.[ ] ]+ (i.e. one or more cs that aren't ".", "[", or "]")
+Tail                => '.' Key Tail   |
+                       ‘[' ? ‘]’ Tail |
+                       '[' * ']' Tail |
+                       '[' N ']' Tail |
+                       ""
 
-PrimitivePredicate => NullPredicate       |
-                      ExistsPredicate     |
-                      BooleanPredicate    |
-                      NumericPredicate    |
-                      TimePredicate       |
-                      StringPredicate
+PredicateExpression => (See the comments of expression.Parser#Parse)
 
-NullPredicate      => -null
-ExistsPredicate    => -exists
-BooleanPredicate   => -true | -false
+Predicate           => ObjectPredicate     |
+                       ArrayPredicate      |
+                       PrimitivePredicate
 
-NumericPredicate   => (+|-)? Number
-Number             => N | '{' N '}' | numeric.SizeRegex
+ObjectPredicate     => EmptyPredicate | ‘.’ Key Predicate
 
-TimePredicate      => (+|-)? Duration
-Duration           => numeric.DurationRegex | '{' numeric.DurationRegex '}'
+ArrayPredicate      => EmptyPredicate      |
+                       ‘[' ? ‘]’ Predicate |
+                       ‘[' * ‘]’ Predicate |
+                       ‘[' N ‘]’ Predicate |
 
-StringPredicate    => [^-].*
+PrimitivePredicate  => NullPredicate       |
+                       ExistsPredicate     |
+                       BooleanPredicate    |
+                       NumericPredicate    |
+                       TimePredicate       |
+                       StringPredicate
 
-N                  => \d+ (i.e. some number > 0)
+NullPredicate       => -null
+ExistsPredicate     => -exists
+BooleanPredicate    => -true | -false
+
+NumericPredicate    => (+|-)? Number
+Number              => N | '{' N '}' | numeric.SizeRegex
+
+TimePredicate       => (+|-)? Duration
+Duration            => numeric.DurationRegex | '{' numeric.DurationRegex '}'
+
+StringPredicate     => [^-].*
+
+N                   => \d+ (i.e. some number > 0)
 */
 //nolint
 var metaPrimary = Parser.newPrimary([]string{"-meta", "-m"}, meta.Parse)
diff --git a/cmd/internal/find/primary/meta/arrayPredicate.go b/cmd/internal/find/primary/meta/arrayPredicate.go
index 7f20707..62bcdea 100644
--- a/cmd/internal/find/primary/meta/arrayPredicate.go
+++ b/cmd/internal/find/primary/meta/arrayPredicate.go
@@ -6,17 +6,28 @@ import (
 	"strings"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 // ArrayPredicate => EmptyPredicate      |
 //                   ‘[' ? ‘]’ Predicate |
 //                   ‘[' * ‘]’ Predicate |
 //                   ‘[' N ‘]’ Predicate |
-func parseArrayPredicate(tokens []string) (Predicate, []string, error) {
+func parseArrayPredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if p, tokens, err := parseEmptyPredicate(tokens); err == nil {
 		return p, tokens, err
 	}
 	// EmptyPredicate did not match
+	parsePredicate := predicate.ToParser(parsePredicate)
+	return parseArrayP(
+		tokens,
+		parsePredicate,
+		parsePredicate,
+	)
+}
+
+// This helper's used by parseArrayPredicate and parseArrayExpression
+func parseArrayP(tokens []string, baseCaseParser, keySequenceParser predicate.Parser) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 {
 		return nil, nil, errz.NewMatchError("expected an opening '['")
 	}
@@ -28,8 +39,11 @@ func parseArrayPredicate(tokens []string) (Predicate, []string, error) {
 	if err != nil {
 		return nil, nil, err
 	}
+
+	var p predicate.Predicate
 	if len(token) == 0 {
 		tokens = tokens[1:]
+		p, tokens, err = baseCaseParser.Parse(tokens)
 	} else {
 		// token may be part of a key sequence (e.g. something like
 		// [?].key2 or [?][?])
@@ -40,8 +54,9 @@ func parseArrayPredicate(tokens []string) (Predicate, []string, error) {
 			return nil, nil, fmt.Errorf("expected a '.' or '[' after ']' but got %v instead", token)
 		}
 		tokens[0] = token
+		p, tokens, err = keySequenceParser.Parse(tokens)
 	}
-	p, tokens, err := parsePredicate(tokens)
+
 	if err != nil {
 		if errz.IsMatchError(err) {
 			parsedToken := rawToken[0 : len(rawToken)-len(token)]
@@ -101,12 +116,16 @@ func parseArrayPredicateType(token string) (arrayPredicateType, string, error) {
 	return ptype, token[endIx+1:], nil
 }
 
-func arrayP(ptype arrayPredicateType, p Predicate) Predicate {
+func arrayP(ptype arrayPredicateType, p predicate.Predicate) predicate.Predicate {
+	arryP := &arrayPredicate{
+		ptype: ptype,
+		p: p,
+	}
 	switch ptype.t {
 	case 's':
-		return toArrayP(func(vs []interface{}) bool {
+		arryP.genericPredicate = toArrayP(func(vs []interface{}) bool {
 			for _, v := range vs {
-				if p(v) {
+				if p.IsSatisfiedBy(v) {
 					return true
 				}
 			}
@@ -115,9 +134,9 @@ func arrayP(ptype arrayPredicateType, p Predicate) Predicate {
 			return false
 		})
 	case 'a':
-		return toArrayP(func(vs []interface{}) bool {
+		arryP.genericPredicate = toArrayP(func(vs []interface{}) bool {
 			for _, v := range vs {
-				if !p(v) {
+				if !p.IsSatisfiedBy(v) {
 					return false
 				}
 			}
@@ -126,26 +145,54 @@ func arrayP(ptype arrayPredicateType, p Predicate) Predicate {
 		})
 	case 'n':
 		n := ptype.n
-		return toArrayP(func(vs []interface{}) bool {
+		arryP.genericPredicate = toArrayP(func(vs []interface{}) bool {
 			if n >= uint(len(vs)) {
 				return false
 			}
-			return p(vs[n])
+			return p.IsSatisfiedBy(vs[n])
 		})
 	default:
 		msg := fmt.Sprintf("meta.arrayP called with an unkown ptype %v", ptype.t)
 		panic(msg)
 	}
+	return arryP
 }
 
 // toArrayP is a helper for arrayP that's meant to reduce
 // the boilerplate type validation.
-func toArrayP(p func([]interface{}) bool) Predicate {
-	return func(v interface{}) bool {
+func toArrayP(p func([]interface{}) bool) genericPredicate {
+	return genericPredicate(func(v interface{}) bool {
 		arrayV, ok := v.([]interface{})
 		if !ok {
 			return false
 		}
 		return p(arrayV)
+	})
+}
+
+type arrayPredicate struct {
+	genericPredicate
+	ptype arrayPredicateType
+	p predicate.Predicate
+}
+
+func (arryP *arrayPredicate) Negate() predicate.Predicate {
+	switch t := arryP.ptype.t; t {
+	case 's':
+		// Not("Some element satisfies p") => "All elements satisfy Not(p)"
+		ptype := arrayPredicateType{}
+		ptype.t = 'a'
+		return arrayP(ptype, arryP.p.Negate())
+	case 'a':
+		// Not("All elements satisfy p") => "Some element satisfies Not(p)"
+		ptype := arrayPredicateType{}
+		ptype.t = 's'
+		return arrayP(ptype, arryP.p.Negate())
+	case 'n':
+		// Not("Nth element satisfies p") => "Nth element satisfies Not(p)"
+		return arrayP(arryP.ptype, arryP.p.Negate())
+	default:
+		msg := fmt.Sprintf("meta.arrayPredicate contains an unknown ptype %v", t)
+		panic(msg)
 	}
 }
diff --git a/cmd/internal/find/primary/meta/emptyPredicate.go b/cmd/internal/find/primary/meta/emptyPredicate.go
index e38656c..fbbaa27 100644
--- a/cmd/internal/find/primary/meta/emptyPredicate.go
+++ b/cmd/internal/find/primary/meta/emptyPredicate.go
@@ -2,23 +2,45 @@ package meta
 
 import (
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 // emptyPredicate => -empty
-func parseEmptyPredicate(tokens []string) (Predicate, []string, error) {
+func parseEmptyPredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 || tokens[0] != "-empty" {
 		return nil, nil, errz.NewMatchError("expected '-empty'")
 	}
-	return emptyP, tokens[1:], nil
+	return emptyP(false), tokens[1:], nil
 }
 
-func emptyP(v interface{}) bool {
-	switch t := v.(type) {
-	case map[string]interface{}:
-		return len(t) == 0
-	case []interface{}:
-		return len(t) == 0
-	default:
-		return false
+func emptyP(negated bool) predicate.Predicate {
+	return &emptyPredicate{
+		genericPredicate: func(v interface{}) bool {
+			switch t := v.(type) {
+			case map[string]interface{}:
+				if negated {
+					return len(t) > 0
+				}
+				return len(t) == 0
+			case []interface{}:
+				if negated {
+					return len(t) > 0
+				}
+				return len(t) == 0
+			default:
+				return false
+			}	
+		},
+		negated: negated,
 	}
 }
+
+type emptyPredicate struct {
+	genericPredicate
+	negated bool
+}
+
+func (p *emptyPredicate) Negate() predicate.Predicate {
+	return emptyP(!p.negated)
+} 
+
diff --git a/cmd/internal/find/primary/meta/expression.go b/cmd/internal/find/primary/meta/expression.go
new file mode 100644
index 0000000..3a43fa4
--- /dev/null
+++ b/cmd/internal/find/primary/meta/expression.go
@@ -0,0 +1,106 @@
+package meta
+
+import (
+	"fmt"
+
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
+)
+
+// The functionality here is tested in primary/meta_test.go
+
+// Expression => EmptyPredicate | KeySequence PredicateExpression
+//
+// To simplify the parsing logic, we will change this to
+//     Expression       => EmptyPredicate | ObjectExpression
+//     ObjectExpression => '.' Key (PredicateExpression | OAExpression)
+//     OAExpression     => ObjectExpression |
+//                         ArrayExpression
+//     ArrayExpression  => ‘[' ? ‘]’ (PredicateExpression | OAExpression) |
+//                         ‘[' * ‘]’ (PredicateExpression | OAExpression) |
+//                         ‘[' N ‘]’ (PredicateExpression | OAExpression) |
+// 
+// For ObjectExpression/ArrayExpression, assume their parsers are given a tokens
+// array that's something like [<token>, <rest>...]. Then, if <token> does not
+// contain a key sequence, <rest> will be parsed as a PredicateExpression. Otherwise,
+// let <token> = <head>:<tail>, where <head> is the "'.' Key" part in ObjectExpression,
+// or the "'[' ? ']'" part in ArrayExpression. Then, [<tail>, <rest>...] will be parsed
+// as an OAExpression.
+//
+// NOTE: The rules in ^ are necessary so that something like
+//     -meta .key1 .key2 -true -a .key3 -false
+// is parsed as "the value of the 'key1' key in the entry's metadata is an object with 'key2'
+// set to true AND is an object with 'key3' set to false". Otherwise, if we constructed the
+// grammar as something like:
+//     ObjectExpression => '.' Key Expr
+//     Expr             => ObjectExpression    |
+//                         ArrayExpression     |
+//                         PredicateExpression
+//     ArrayExpression  => '[' ? ']' Expr | ...
+//
+// (to mirror the symmetry of ObjectPredicate/ArrayPredicate), then our example would instead
+// be parsed as "the value of the 'key1.key2' key in the entry's metadata is set to true AND it
+// is an object with 'key3' set to false". We could get our example to parse correctly with the
+// above grammar via something like "-meta .key1 \( .key2 -true -a .key3 -false \)", but that is
+// annoying and unnecessary clutter.
+//    
+// Thus, the rules for PredicateExpression/OAExpression allow one to cleanly combine object/array
+// predicates on entry metadata values without having to use a parentheses. 
+func parseExpression(tokens []string) (predicate.Predicate, []string, error) {
+	if p, tokens, err := parseEmptyPredicate(tokens); err == nil {
+		return p, tokens, err
+	}
+	p, tokens, err := parseObjectExpression(tokens)
+	if err != nil {
+		if errz.IsMatchError(err) {
+			// We expect an ObjectExpression, so treat any match errors
+			// as syntax errors.
+			err = fmt.Errorf(err.Error())
+		}
+		return nil, nil, err
+	}
+	return p, tokens, nil
+}
+
+func parseObjectExpression(tokens []string) (predicate.Predicate, []string, error) {
+	return parseObjectP(
+		tokens,
+		predicate.ToParser(parsePredicateExpression),
+		predicate.ToParser(parseOAExpression),
+	)
+}
+
+func parseOAExpression(tokens []string) (predicate.Predicate, []string, error) {
+	cp := &predicate.CompositeParser{
+		// This error message should never be used. The following comment explains why.
+		MatchErrMsg: "",
+		Parsers: []predicate.Parser{
+			predicate.ToParser(parseObjectExpression),
+			predicate.ToParser(parseArrayExpression),
+		},
+	}
+	p, tokens, err := cp.Parse(tokens)
+	if err != nil {
+		if errz.IsMatchError(err) {
+			// We should never hit this code-path because parseOAExpression
+			// will only be called by parseObjectExpression/parseArrayExpression
+			// when either method receives a key sequence. Key sequences will always
+			// match parseObjectExpression/parseArrayExpression.
+			msg := fmt.Sprintf(
+				"meta.parseOAExpression: predicate.CompositeParser#Parse returned a match error on a key sequence. Input: %v",
+				tokens,
+			)
+			panic(msg)
+		}
+		return nil, nil, err
+	}
+	return p, tokens, nil
+}
+
+func parseArrayExpression(tokens []string) (predicate.Predicate, []string, error) {
+	return parseArrayP(
+		tokens,
+		predicate.ToParser(parsePredicateExpression),
+		predicate.ToParser(parseOAExpression),
+	)
+}
\ No newline at end of file
diff --git a/cmd/internal/find/primary/meta/numericPredicate.go b/cmd/internal/find/primary/meta/numericPredicate.go
index ceec6b6..118649e 100644
--- a/cmd/internal/find/primary/meta/numericPredicate.go
+++ b/cmd/internal/find/primary/meta/numericPredicate.go
@@ -4,17 +4,18 @@ import (
 	"fmt"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/puppetlabs/wash/cmd/internal/find/primary/numeric"
 )
 
 // NumericPredicate => (+|-)? Number
 // Number           => N | '{' N '}' | numeric.SizeRegex
-func parseNumericPredicate(tokens []string) (Predicate, []string, error) {
+func parseNumericPredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 {
 		return nil, nil, errz.NewMatchError("expected a +, -, or a digit")
 	}
 	token := tokens[0]
-	numericP, _, err := numeric.ParsePredicate(
+	p, _, err := numeric.ParsePredicate(
 		token,
 		numeric.ParsePositiveInt,
 		numeric.Bracket(numeric.Negate(numeric.ParsePositiveInt)),
@@ -28,12 +29,27 @@ func parseNumericPredicate(tokens []string) (Predicate, []string, error) {
 		// err is a parse error, so return it.
 		return nil, nil, err
 	}
-	p := func(v interface{}) bool {
-		floatV, ok := v.(float64)
-		if !ok {
-			return false
-		}
-		return numericP(int64(floatV))
+	return numericP(p), tokens[1:], nil
+}
+
+func numericP(p numeric.Predicate) predicate.Predicate {
+	return &numericPredicate{
+		genericPredicate: func(v interface{}) bool {
+			floatV, ok := v.(float64)
+			if !ok {
+				return false
+			}
+			return p(int64(floatV))
+		},
+		p: p,
 	}
-	return p, tokens[1:], nil
+}
+
+type numericPredicate struct {
+	genericPredicate
+	p numeric.Predicate
+}
+
+func (np *numericPredicate) Negate() predicate.Predicate {
+	return numericP(np.p.Negate().(numeric.Predicate))
 }
diff --git a/cmd/internal/find/primary/meta/objectPredicate.go b/cmd/internal/find/primary/meta/objectPredicate.go
index 01414e9..cddb20f 100644
--- a/cmd/internal/find/primary/meta/objectPredicate.go
+++ b/cmd/internal/find/primary/meta/objectPredicate.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 // A key consists of one or more characters that aren't
@@ -18,11 +19,21 @@ var keyRegex = regexp.MustCompile(`^([^\.\[\]]+)`)
 
 // ObjectPredicate => EmptyPredicate | ‘.’ Key Predicate
 // Key             => keyRegex
-func parseObjectPredicate(tokens []string) (Predicate, []string, error) {
+func parseObjectPredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if p, tokens, err := parseEmptyPredicate(tokens); err == nil {
 		return p, tokens, err
 	}
 	// EmptyPredicate did not match, so try '.' Key Predicate
+	parsePredicate := predicate.ToParser(parsePredicate)
+	return parseObjectP(
+		tokens,
+		parsePredicate,
+		parsePredicate,
+	)
+}
+
+// This helper's used by parseObjectPredicate and parseObjectExpression.
+func parseObjectP(tokens []string, baseCaseParser, keySequenceParser predicate.Parser) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 {
 		return nil, nil, errz.NewMatchError("expected a key sequence")
 	}
@@ -36,18 +47,22 @@ func parseObjectPredicate(tokens []string) (Predicate, []string, error) {
 		return nil, nil, fmt.Errorf("expected a key sequence after '.'")
 	}
 	key := tk[loc[0]:loc[1]]
+
+	var p predicate.Predicate
+	var err error
 	if len(tk) == loc[1] {
-		// tk is a single key, so it is of the form "key". Since keyRegex matched "key",
-		// we can shift tokens.
+		// tk is a single key, so it is of the form "key". This is the base case.
 		tokens = tokens[1:]
+		p, tokens, err = baseCaseParser.Parse(tokens)
 	} else {
 		// tk is a key sequence, so it is of the form "key1.key2" (or "key1[?]"). keyRegex
 		// matched the "key1" part, while the ".key2"/"[?]" parts correspond to object/array
-		// predicates. We can let parsePredicate figure this info out for us by setting
-		// tokens[0] to the regex's postmatch prior to passing tokens into parsePredicate.
+		// predicates. We can let keySequenceParser figure this info out for us by setting
+		// tokens[0] to the regex's postmatch prior to passing it in.
 		tokens[0] = tk[loc[1]:]
+		p, tokens, err = keySequenceParser.Parse(tokens)
 	}
-	p, tokens, err := parsePredicate(tokens)
+
 	if err != nil {
 		if errz.IsMatchError(err) {
 			return nil, nil, fmt.Errorf("expected a predicate after %v", key)
@@ -57,18 +72,22 @@ func parseObjectPredicate(tokens []string) (Predicate, []string, error) {
 	return objectP(key, p), tokens, nil
 }
 
-func objectP(key string, p Predicate) Predicate {
-	return func(v interface{}) bool {
-		mp, ok := v.(map[string]interface{})
-		if !ok {
-			return false
-		}
-		matchingKey := findMatchingKey(mp, key)
-		if matchingKey == "" {
-			// key doesn't exist in mp
-			return false
-		}
-		return p(mp[matchingKey])
+func objectP(key string, p predicate.Predicate) predicate.Predicate {
+	return &objectPredicate{
+		genericPredicate: func(v interface{}) bool {
+			mp, ok := v.(map[string]interface{})
+			if !ok {
+				return false
+			}
+			matchingKey := findMatchingKey(mp, key)
+			if matchingKey == "" {
+				// key doesn't exist in mp
+				return false
+			}
+			return p.IsSatisfiedBy(mp[matchingKey])
+		},
+		key: key,
+		p: p,
 	}
 }
 
@@ -81,3 +100,13 @@ func findMatchingKey(mp map[string]interface{}, key string) string {
 	}
 	return ""
 }
+
+type objectPredicate struct {
+	genericPredicate
+	key string
+	p predicate.Predicate
+}
+
+func (objP *objectPredicate) Negate() predicate.Predicate {
+	return objectP(objP.key, objP.p.Negate())
+}
diff --git a/cmd/internal/find/primary/meta/parse.go b/cmd/internal/find/primary/meta/parse.go
index e5e0a8d..c22189e 100644
--- a/cmd/internal/find/primary/meta/parse.go
+++ b/cmd/internal/find/primary/meta/parse.go
@@ -5,14 +5,16 @@ import (
 	"github.com/puppetlabs/wash/cmd/internal/find/types"
 )
 
+// The functionality here is tested in primary/meta_test.go
+
 // Parse is the meta primary's parse function.
 func Parse(tokens []string) (types.EntryPredicate, []string, error) {
-	p, tokens, err := parseObjectPredicate(tokens)
+	p, tokens, err := parseExpression(tokens)
 	if err != nil {
 		return nil, nil, err
 	}
 	return func(e types.Entry) bool {
 		mp := map[string]interface{}(e.Attributes.Meta())
-		return p(mp)
+		return p.IsSatisfiedBy(mp)
 	}, tokens, nil
 }
diff --git a/cmd/internal/find/primary/meta/predicate.go b/cmd/internal/find/primary/meta/predicate.go
index 1a38e7d..aaf6e06 100644
--- a/cmd/internal/find/primary/meta/predicate.go
+++ b/cmd/internal/find/primary/meta/predicate.go
@@ -4,61 +4,59 @@ import (
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
-// Predicate represents a meta primary predicate.
-type Predicate func(interface{}) bool
+/*
+Predicate => ObjectPredicate |
+             ArrayPredicate  |
+             PrimitivePredicate
+*/
+func parsePredicate(tokens []string) (predicate.Predicate, []string, error) {
+	cp := &predicate.CompositeParser{
+		MatchErrMsg: "expected either a primitive, object, or array predicate",
+		Parsers: []predicate.Parser{
+			predicate.ToParser(parseObjectPredicate),
+			predicate.ToParser(parseArrayPredicate),
+			predicate.ToParser(parsePrimitivePredicate),
+		},
+	}
+	return cp.Parse(tokens)
+}
+
+// genericPredicate represents a `meta` primary predicate "base" class.
+// Child classes should only override the Negate method. Here's why:
+//   * Some `meta` primary predicates perform type validation, returning
+//     false for a mis-typed value. genericPredicate#Negate is a strict
+//     negation, so it will return true for a mis-typed value. This is bad.
+//
+//   * Some of the more complicated predicates require additional negation
+//     semantics. For example, ObjectPredicate returns false if the key does
+//     not exist. A negated ObjectPredicate should also return false for this
+//     case.
+//
+// Both of these issues are resolved if the child class overrides Negate.
+type genericPredicate func(interface{}) bool
 
 // And returns p1 && p2
-func (p1 Predicate) And(p2 predicate.Predicate) predicate.Predicate {
-	return Predicate(func(v interface{}) bool {
-		return p1(v) && (p2.(Predicate))(v)
+func (p1 genericPredicate) And(p2 predicate.Predicate) predicate.Predicate {
+	return genericPredicate(func(v interface{}) bool {
+		return p1(v) && p2.IsSatisfiedBy(v)
 	})
 }
 
 // Or returns p1 || p2
-func (p1 Predicate) Or(p2 predicate.Predicate) predicate.Predicate {
-	return Predicate(func(v interface{}) bool {
-		return p1(v) || (p2.(Predicate))(v)
+func (p1 genericPredicate) Or(p2 predicate.Predicate) predicate.Predicate {
+	return genericPredicate(func(v interface{}) bool {
+		return p1(v) || p2.IsSatisfiedBy(v)
 	})
 }
 
 // Negate returns Not(p1)
-func (p1 Predicate) Negate() predicate.Predicate {
-	return Predicate(func(v interface{}) bool {
+func (p1 genericPredicate) Negate() predicate.Predicate {
+	return genericPredicate(func(v interface{}) bool {
 		return !p1(v)
 	})
 }
 
 // IsSatisfiedBy returns true if v satisfies the predicate, false otherwise
-func (p1 Predicate) IsSatisfiedBy(v interface{}) bool {
+func (p1 genericPredicate) IsSatisfiedBy(v interface{}) bool {
 	return p1(v)
 }
-
-/*
-Predicate => ObjectPredicate |
-             ArrayPredicate  |
-             PrimitivePredicate
-*/
-func parsePredicate(tokens []string) (Predicate, []string, error) {
-	cp := &predicate.CompositeParser{
-		ErrMsg: "expected either a primitive, object, or array predicate",
-		Parsers: []predicate.Parser{
-			predicateParser(parseObjectPredicate),
-			predicateParser(parseArrayPredicate),
-			predicateParser(parsePrimitivePredicate),
-		},
-	}
-	p, tokens, err := cp.Parse(tokens)
-	if err != nil {
-		return nil, nil, err
-	}
-	return p.(Predicate), tokens, err
-}
-
-
-type predicateParser func(tokens []string) (Predicate, []string, error)
-
-// Parse parses a meta primary predicate from the given input.
-func (parser predicateParser) Parse(tokens []string) (predicate.Predicate, []string, error) {
-	return parser(tokens)
-}
-
diff --git a/cmd/internal/find/primary/meta/predicateExpression.go b/cmd/internal/find/primary/meta/predicateExpression.go
new file mode 100644
index 0000000..15cf1f9
--- /dev/null
+++ b/cmd/internal/find/primary/meta/predicateExpression.go
@@ -0,0 +1,31 @@
+package meta
+
+import (
+	"fmt"
+
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/expression"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
+)
+
+// PredicateExpression => (See the comments of expression.Parser#Parse)
+func parsePredicateExpression(tokens []string) (predicate.Predicate, []string, error) {
+	if len(tokens) == 0 {
+		return nil, nil, fmt.Errorf("expected a predicate expression")
+	}
+	parser := expression.NewParser(predicate.ToParser(parsePredicate))
+	p, tks, err := parser.Parse(tokens)
+	if err != nil {
+		tkErr, ok := err.(expression.UnknownTokenError)
+		if !ok {
+			// We have a syntax error
+			return nil, nil, err
+		}
+		if p == nil {
+			// possible via something like "-size + 1"
+			return nil, nil, fmt.Errorf("unknown predicate %v", tkErr.Token)
+		}
+	}
+	// If err != nil here, then err is an UnknownTokenError. An UnknownTokenError
+	// means we've finished parsing the `meta` primary's predicate expression.
+	return p, tks, nil
+}
\ No newline at end of file
diff --git a/cmd/internal/find/primary/meta/primitivePredicate.go b/cmd/internal/find/primary/meta/primitivePredicate.go
index c5c0ad8..7c18126 100644
--- a/cmd/internal/find/primary/meta/primitivePredicate.go
+++ b/cmd/internal/find/primary/meta/primitivePredicate.go
@@ -2,6 +2,7 @@ package meta
 
 import (
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 /*
@@ -12,17 +13,17 @@ PrimitivePredicate => NullPredicate       |
                       TimePredicate       |
                       StringPredicate
 */
-func parsePrimitivePredicate(tokens []string) (Predicate, []string, error) {
+func parsePrimitivePredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 {
 		return nil, nil, errz.NewMatchError("expected a primitive predicate")
 	}
 	switch token := tokens[0]; token {
 	case "-null":
 		// NullPredicate
-		return nullP, tokens[1:], nil
+		return genericPredicate(nullP), tokens[1:], nil
 	case "-exists":
 		// ExistsPredicate
-		return existsP, tokens[1:], nil
+		return genericPredicate(existsP), tokens[1:], nil
 	case "-true":
 		// BooleanPredicate
 		return trueP, tokens[1:], nil
@@ -76,15 +77,27 @@ func existsP(v interface{}) bool {
 	return v != nil
 }
 
-var trueP = makeBooleanPredicate(true)
-var falseP = makeBooleanPredicate(false)
+var trueP = booleanP(true)
+var falseP = booleanP(false)
 
-func makeBooleanPredicate(expectedVal bool) Predicate {
-	return func(v interface{}) bool {
-		bv, ok := v.(bool)
-		if !ok {
-			return false
-		}
-		return bv == expectedVal
+func booleanP(value bool) predicate.Predicate {
+	return &booleanPredicate{
+		genericPredicate: func(v interface{}) bool {
+			bv, ok := v.(bool)
+			if !ok {
+				return false
+			}
+			return bv == value
+		},
+		value: value,
 	}
 }
+
+type booleanPredicate struct {
+	genericPredicate
+	value bool
+}
+
+func (bp *booleanPredicate) Negate() predicate.Predicate {
+	return booleanP(!bp.value)
+}
diff --git a/cmd/internal/find/primary/meta/stringPredicate.go b/cmd/internal/find/primary/meta/stringPredicate.go
index 6ef6be5..e91a5be 100644
--- a/cmd/internal/find/primary/meta/stringPredicate.go
+++ b/cmd/internal/find/primary/meta/stringPredicate.go
@@ -2,28 +2,57 @@ package meta
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 // StringPredicate => [^-].*
-func parseStringPredicate(tokens []string) (Predicate, []string, error) {
+func parseStringPredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if len(tokens) == 0 || len(tokens[0]) == 0 {
 		return nil, nil, errz.NewMatchError("expected a nonempty string")
 	}
 	token := tokens[0]
-	if token[0] == '-' {
+	if strings.Contains("-()!", string(token[0])) {
 		// We impose this restriction to avoid conflicting with the
-		// other primaries
-		msg := fmt.Sprintf("%v begins with a '-'", token)
-		return nil, nil, errz.NewMatchError(msg)
-	}
-	p := func(v interface{}) bool {
-		strV, ok := v.(string)
-		if !ok {
-			return false
+		// other primaries and any expression operators. Note that
+		// this behavior's tested in parsePredicate's tests.
+		var msg string
+		if token[0] == '-' {
+			msg = fmt.Sprintf("%v begins with a '-'", token)
+		} else if len(token) == 1 {
+			// token[0] is "(", ")", or "!"
+			msg = fmt.Sprintf("%v is an expression operator", token)
 		}
-		return strV == token
+		return nil, nil, errz.NewMatchError(msg)
 	}
+	p := stringP(func(s string) bool {
+		return s == token
+	})
 	return p, tokens[1:], nil
 }
+
+func stringP(p func(string) bool) predicate.Predicate {
+	return &stringPredicate{
+		genericPredicate: func(v interface{}) bool {
+			strV, ok := v.(string)
+			if !ok {
+				return false
+			}
+			return p(strV)
+		},
+		p: p,
+	}
+}
+
+type stringPredicate struct {
+	genericPredicate
+	p func(string) bool
+}
+
+func (sp *stringPredicate) Negate() predicate.Predicate {
+	return stringP(func(s string) bool {
+		return !sp.p(s)
+	})
+}
diff --git a/cmd/internal/find/primary/meta/timePredicate.go b/cmd/internal/find/primary/meta/timePredicate.go
index 40a3ca7..ec2d999 100644
--- a/cmd/internal/find/primary/meta/timePredicate.go
+++ b/cmd/internal/find/primary/meta/timePredicate.go
@@ -5,13 +5,14 @@ import (
 
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/puppetlabs/wash/cmd/internal/find/primary/numeric"
 	"github.com/puppetlabs/wash/munge"
 )
 
 // TimePredicate => (+|-)? Duration
 // Duration      => numeric.DurationRegex | '{' numeric.DurationRegex '}'
-func parseTimePredicate(tokens []string) (Predicate, []string, error) {
+func parseTimePredicate(tokens []string) (predicate.Predicate, []string, error) {
 	if params.StartTime.IsZero() {
 		panic("meta.parseTimePredicate called without setting params.StartTime")
 	}
@@ -19,7 +20,7 @@ func parseTimePredicate(tokens []string) (Predicate, []string, error) {
 		return nil, nil, errz.NewMatchError("expected a +, -, or a digit")
 	}
 	token := tokens[0]
-	numericP, parserID, err := numeric.ParsePredicate(
+	p, parserID, err := numeric.ParsePredicate(
 		token,
 		numeric.ParseDuration,
 		numeric.Bracket(numeric.ParseDuration),
@@ -39,28 +40,45 @@ func parseTimePredicate(tokens []string) (Predicate, []string, error) {
 		// 'StartTime - timeV'.
 		subFromStartTime = false
 	}
-	p := func(v interface{}) bool {
-		timeV, err := munge.ToTime(v)
-		if err != nil {
-			return false
-		}
-		var diff int64
-		if subFromStartTime {
-			diff = int64(params.StartTime.Sub(timeV))
-		} else {
-			diff = int64(timeV.Sub(params.StartTime))
-		}
-		if diff < 0 {
-			// Time predicates query either the past or the future, but not both.
-			// For example, +1h means "more than one hour ago", which queries the
-			// past. diff < 0 there means timeV is from the future, so the query
-			// doesn't make sense. Similarly, +{1h} means "more than one hour from now",
-			// which queries the future. diff < 0 there means timeV is from the past,
-			// so the query doesn't make sense. Thus, diff < 0 is a time-mismatch.
-			// Time-mismatches always return false.
-			return false
-		}
-		return numericP(diff)
+	return timeP(subFromStartTime, p), tokens[1:], nil
+}
+
+func timeP(subFromStartTime bool, p numeric.Predicate) predicate.Predicate {
+	return &timePredicate{
+		genericPredicate: func(v interface{}) bool {
+			timeV, err := munge.ToTime(v)
+			if err != nil {
+				return false
+			}
+			var diff int64
+			if subFromStartTime {
+				diff = int64(params.StartTime.Sub(timeV))
+			} else {
+				diff = int64(timeV.Sub(params.StartTime))
+			}
+			if diff < 0 {
+				// Time predicates query either the past or the future, but not both.
+				// For example, +1h means "more than one hour ago", which queries the
+				// past. diff < 0 there means timeV is from the future, so the query
+				// doesn't make sense. Similarly, +{1h} means "more than one hour from now",
+				// which queries the future. diff < 0 there means timeV is from the past,
+				// so the query doesn't make sense. Thus, diff < 0 is a time-mismatch.
+				// Time-mismatches always return false.
+				return false
+			}
+			return p(diff)
+		},
+		subFromStartTime: subFromStartTime,
+		p: p,
 	}
-	return p, tokens[1:], nil
+}
+
+type timePredicate struct {
+	genericPredicate
+	subFromStartTime bool
+	p numeric.Predicate
+}
+
+func (tp *timePredicate) Negate() predicate.Predicate {
+	return timeP(tp.subFromStartTime, tp.p.Negate().(numeric.Predicate))
 }
diff --git a/cmd/internal/find/primary/numeric/predicate.go b/cmd/internal/find/primary/numeric/predicate.go
index 9b21637..40a1fe6 100644
--- a/cmd/internal/find/primary/numeric/predicate.go
+++ b/cmd/internal/find/primary/numeric/predicate.go
@@ -4,11 +4,42 @@ import (
 	"fmt"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/errz"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 )
 
 // Predicate represents a Numeric predicate
 type Predicate func(int64) bool
 
+// And returns p1 && p2
+func (p1 Predicate) And(p2 predicate.Predicate) predicate.Predicate {
+	return Predicate(func(n int64) bool {
+		return p1(n) && (p2.(Predicate))(n)
+	})
+}
+
+// Or returns p1 || p2
+func (p1 Predicate) Or(p2 predicate.Predicate) predicate.Predicate {
+	return Predicate(func(n int64) bool {
+		return p1(n) || (p2.(Predicate))(n)
+	})
+}
+
+// Negate returns Not(p1)
+func (p1 Predicate) Negate() predicate.Predicate {
+	return Predicate(func(n int64) bool {
+		return !p1(n)
+	})
+}
+
+// IsSatisfiedBy returns true if v satisfies the predicate, false otherwise
+func (p1 Predicate) IsSatisfiedBy(v interface{}) bool {
+	n, ok := v.(int64)
+	if !ok {
+		return false
+	}
+	return p1(n)
+}
+
 // Parser parses numeric values.
 type Parser func(string) (int64, error)
 
@@ -25,6 +56,7 @@ func ParsePredicate(str string, parsers ...Parser) (Predicate, int, error) {
 		panic("numeric.ParsePredicate called without any parsers")
 	}
 
+	// TODO: Introduce "+="/"-=" to represent ">="/"<="?
 	cmp := str[0]
 	if cmp == '+' || cmp == '-' {
 		str = str[1:]
diff --git a/cmd/internal/find/primary/parser.go b/cmd/internal/find/primary/parser.go
index 25f75c1..e49033d 100644
--- a/cmd/internal/find/primary/parser.go
+++ b/cmd/internal/find/primary/parser.go
@@ -11,7 +11,7 @@ import (
 // Parser parses `wash find` primaries.
 var Parser = &parser{
 	CompositeParser: &predicate.CompositeParser{
-		ErrMsg: "unknown primary",
+		MatchErrMsg: "unknown primary",
 	},
 	primaries: make(map[string]*primary),
 }
diff --git a/docs/index.html b/docs/index.html
index 13adff3..1de80ba 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 <html>
 <head>
-	<meta name="generator" content="Hugo 0.55.5" />
+	<meta name="generator" content="Hugo 0.55.2" />
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
 
@@ -37,7 +37,7 @@
 
 <p><code>wash</code> helps you deal with all your remote or cloud-native infrastructure using the UNIX-y patterns and tools you already know and love.</p>
 
-<script id="asciicast-245046" src="https://asciinema.org/a/245046.js" async></script>
+<p><em>TBD: Screencast</em></p>
 
 <p><code>wash</code> aims to:</p>
 
diff --git a/go.mod b/go.mod
index 1e6d174..f1dd48f 100644
--- a/go.mod
+++ b/go.mod
@@ -42,7 +42,6 @@ require (
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.6 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
-	github.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e
 	github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/mattn/go-colorable v0.1.1 // indirect
@@ -51,7 +50,6 @@ require (
 	github.com/modern-go/reflect2 v1.0.1 // indirect
 	github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
 	github.com/opencontainers/image-spec v1.0.1 // indirect
-	github.com/pelletier/go-buffruneio v0.2.0 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pkg/errors v0.8.1
 	github.com/shirou/gopsutil v2.18.12+incompatible
@@ -62,7 +60,6 @@ require (
 	github.com/spf13/cobra v0.0.3
 	github.com/spf13/viper v1.3.2
 	github.com/stretchr/testify v1.2.2
-	golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9
 	golang.org/x/net v0.0.0-20190110200230-915654e7eabc // indirect
 	golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890 // indirect
 	golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
diff --git a/go.sum b/go.sum
index 2d2df56..8ed8826 100644
--- a/go.sum
+++ b/go.sum
@@ -101,8 +101,6 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e h1:RgQk53JHp/Cjunrr1WlsXSZpqXn+uREuHvUVcK82CV8=
-github.com/kevinburke/ssh_config v0.0.0-20180830205328-81db2a75821e/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -133,8 +131,6 @@ github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2i
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
 github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/pelletier/go-buffruneio v0.2.0 h1:U4t4R6YkofJ5xHm3dJzuRpPZ0mr5MMCoAWooScCR7aA=
-github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
diff --git a/plugin/aws/ec2Instance.go b/plugin/aws/ec2Instance.go
index 64984ee..219ad9a 100644
--- a/plugin/aws/ec2Instance.go
+++ b/plugin/aws/ec2Instance.go
@@ -3,15 +3,20 @@ package aws
 import (
 	"context"
 	"fmt"
+	"io/ioutil"
+	"strings"
 	"sync"
 	"time"
 
 	awsSDK "github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
 	ec2Client "github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/ssm"
+	"github.com/kballard/go-shellquote"
+	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
-	"github.com/puppetlabs/wash/transport"
 	"github.com/puppetlabs/wash/volume"
 )
 
@@ -21,6 +26,8 @@ type ec2Instance struct {
 	id                      string
 	session                 *session.Session
 	client                  *ec2Client.EC2
+	ssmClient               *ssm.SSM
+	cloudwatchClient        *cloudwatchlogs.CloudWatchLogs
 	latestConsoleOutputOnce sync.Once
 	hasLatestConsoleOutput  bool
 }
@@ -51,21 +58,38 @@ func newEC2Instance(ctx context.Context, inst *ec2Client.Instance, session *sess
 		}
 	}
 	ec2Instance := &ec2Instance{
-		EntryBase: plugin.NewEntry(name),
-		id:        id,
-		session:   session,
-		client:    client,
+		EntryBase:        plugin.NewEntry(name),
+		id:               id,
+		session:          session,
+		client:           client,
+		ssmClient:        ssm.New(session),
+		cloudwatchClient: cloudwatchlogs.New(session),
 	}
 	ec2Instance.SetTTLOf(plugin.ListOp, 30*time.Second)
 	ec2Instance.DisableCachingFor(plugin.MetadataOp)
 
-	ec2Instance.SetAttributes(getAttributes(inst))
+	metaObj := newDescribeInstanceResult(inst)
+
+	attr := plugin.EntryAttributes{}
+	attr.
+		SetCtime(metaObj.ctime).
+		SetMtime(metaObj.mtime).
+		SetMeta(metaObj.toMeta())
+	ec2Instance.SetAttributes(attr)
 
 	return ec2Instance
 }
 
-func getAttributes(inst *ec2Client.Instance) plugin.EntryAttributes {
-	attr := plugin.EntryAttributes{}
+type describeInstanceResult struct {
+	inst  *ec2Client.Instance
+	ctime time.Time
+	mtime time.Time
+}
+
+func newDescribeInstanceResult(inst *ec2Client.Instance) describeInstanceResult {
+	result := describeInstanceResult{
+		inst: inst,
+	}
 
 	// AWS does not include the EC2 instance's ctime in its
 	// metadata. It also does not include the EC2 instance's
@@ -73,30 +97,62 @@ func getAttributes(inst *ec2Client.Instance) plugin.EntryAttributes {
 	// reasonable values for ctime and mtime by looping over each
 	// block device's attachment time and the instance's launch time.
 	// The oldest of these times is the ctime; the newest is the mtime.
-	ctime := awsSDK.TimeValue(inst.LaunchTime)
-	mtime := ctime
+	result.ctime = awsSDK.TimeValue(inst.LaunchTime)
+	result.mtime = result.ctime
 	for _, mapping := range inst.BlockDeviceMappings {
 		attachTime := awsSDK.TimeValue(mapping.Ebs.AttachTime)
 
-		if attachTime.Before(ctime) {
-			ctime = attachTime
+		if attachTime.Before(result.ctime) {
+			result.ctime = attachTime
 		}
 
-		if attachTime.After(mtime) {
-			mtime = attachTime
+		if attachTime.After(result.mtime) {
+			result.mtime = attachTime
 		}
 	}
 
-	meta := plugin.ToMeta(inst)
-	meta["CreationTime"] = ctime
-	meta["LastModifiedTime"] = mtime
+	return result
+}
 
-	attr.
-		SetCtime(ctime).
-		SetMtime(mtime).
-		SetMeta(meta)
+func (d describeInstanceResult) toMeta() plugin.EntryMetadata {
+	meta := plugin.ToMeta(d.inst)
+	meta["CreationTime"] = d.ctime
+	meta["LastModifiedTime"] = d.mtime
 
-	return attr
+	return meta
+}
+
+func (inst *ec2Instance) cachedDescribeInstance(ctx context.Context) (describeInstanceResult, error) {
+	info, err := plugin.CachedOp(ctx, "DescribeInstance", inst, 15*time.Second, func() (interface{}, error) {
+		request := &ec2Client.DescribeInstancesInput{
+			InstanceIds: []*string{
+				awsSDK.String(inst.id),
+			},
+		}
+
+		resp, err := inst.client.DescribeInstances(request)
+		if err != nil {
+			return nil, err
+		}
+
+		inst := resp.Reservations[0].Instances[0]
+		return newDescribeInstanceResult(inst), nil
+	})
+
+	if err != nil {
+		return describeInstanceResult{}, err
+	}
+
+	return info.(describeInstanceResult), nil
+}
+
+func (inst *ec2Instance) Metadata(ctx context.Context) (plugin.EntryMetadata, error) {
+	result, err := inst.cachedDescribeInstance(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return result.toMeta(), nil
 }
 
 func (inst *ec2Instance) List(ctx context.Context) ([]plugin.Entry, error) {
@@ -182,25 +238,457 @@ func (inst *ec2Instance) checkLatestConsoleOutput(ctx context.Context) (*ec2Inst
 	return nil, fmt.Errorf("could not access the latest console log: %v", err)
 }
 
+// These constants represent the possible states that the command
+// could be in. They're needed by Exec. We export these constants
+// so that other packages could use them since they are not provided
+// by the AWS SDK.
+//
+// See https://docs.aws.amazon.com/systems-manager/latest/userguide/monitor-commands.html
+// for a detailed explanation of each of these states.
+const (
+	CommandPending           = "Pending"
+	CommandInProgress        = "InProgress"
+	CommandDelayed           = "Delayed"
+	CommandSuccess           = "Success"
+	CommandDeliveryTimedOut  = "DeliveryTimedOut"
+	CommandExecutionTimedOut = "ExecutionTimedOut"
+	CommandFailed            = "Failed"
+	CommandCanceled          = "Canceled"
+	CommandUndeliverable     = "Undeliverable"
+	CommandTerminated        = "Terminated"
+)
+
 func (inst *ec2Instance) Exec(ctx context.Context, cmd string, args []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
-	meta, err := inst.Metadata(ctx)
+	execResult := plugin.ExecResult{}
+
+	result, err := inst.cachedDescribeInstance(ctx)
+	if err != nil {
+		return execResult, nil
+	}
+
+	// Exec only makes sense on a running EC2 instance.
+	if awsSDK.Int64Value(result.inst.State.Code) != EC2InstanceRunningState {
+		err := fmt.Errorf(
+			"instance is not in the running state. Its current state is: %v",
+			awsSDK.StringValue(result.inst.State.Name),
+		)
+
+		return execResult, err
+	}
+
+	// We're running our command as a shell script so we'll need to do
+	// some shell escaping.
+	cmdStr := shellquote.Join(
+		append([]string{cmd}, args...)...,
+	)
+	if opts.Stdin != nil {
+		input, err := ioutil.ReadAll(opts.Stdin)
+		if err != nil {
+			return execResult, fmt.Errorf("failed to read stdin: %v", err)
+		}
+
+		// Handle Stdin by piping its content into our command.
+		// Note that this won't work well for large Stdin streams.
+		quoted := shellquote.Join(string(input))
+		// Add extra escaping needed for some shells. This should probably be part of shellquote.
+		quoted = strings.Replace(quoted, "\\t", "\\\\t", -1)
+		quoted = strings.Replace(quoted, "\\n", "\\\\n", -1)
+		quoted = strings.Replace(quoted, "\\0", "\\\\0", -1)
+		cmdStr = "echo -n " + quoted + " | " + cmdStr
+	}
+
+	activity.Record(ctx, "Sending the following command to the SSM agent:\n%v", cmdStr)
+	request := &ssm.SendCommandInput{
+		CloudWatchOutputConfig: &ssm.CloudWatchOutputConfig{
+			// When CloudWatch output is enabled, AWS will send stdout/stderr
+			// to the CloudWatch logs.
+			CloudWatchOutputEnabled: awsSDK.Bool(true),
+		},
+		Comment: awsSDK.String("Document triggered by Wash"),
+		// TODO: AWS-RunShellScript only works on Linux instances. We'll need to use
+		// AWS-RunPowerShellScript for Windows.
+		DocumentName: awsSDK.String("AWS-RunShellScript"),
+		InstanceIds: []*string{
+			awsSDK.String(inst.id),
+		},
+		// See https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-plugins.html#aws-runShellScript
+		// for a list of all the parameters that AWS-RunShellScript
+		// supports. Note that there are some naming inconsistencies
+		// between the parameter names, so here is a mapping of
+		// the parameter name in the doc. vs. what you'd pass-in
+		// to the request:
+		//     "runCommand"       => "commands"
+		//     "timeoutSeconds"   => "executionTimeout"
+		//     "workingDirectory" => "workingDirectory"
+		Parameters: map[string][]*string{
+			"commands": awsSDK.StringSlice(
+				[]string{cmdStr},
+			),
+		},
+		// The minimum value is 30 seconds. This guarantees that if our
+		// command has not yet started executing, then it will not run.
+		TimeoutSeconds: awsSDK.Int64(30),
+	}
+	resp, err := inst.ssmClient.SendCommandWithContext(ctx, request)
 	if err != nil {
-		return plugin.ExecResult{}, err
+		return execResult, err
+	}
+
+	commandID := awsSDK.StringValue(resp.Command.CommandId)
+	activity.Record(ctx, "Successfully sent the command. Command ID: %v", commandID)
+
+	outputCh, outputStreamer := newOutputStreamer(ctx, inst.cloudwatchClient, commandID, inst.id)
+	var exitCode int
+	var exitCodeErr error
+	go func() {
+		// Some helpful functions
+		closeOutputStreamer := func(err error) {
+			outputStreamer.CloseWithError(err)
+			exitCodeErr = err
+		}
+		cancelCommand := func(reason string) {
+			activity.Record(ctx, "Cancelling the command. Reason: %v", reason)
+
+			request := &ssm.CancelCommandInput{
+				CommandId: awsSDK.String(commandID),
+				InstanceIds: awsSDK.StringSlice(
+					[]string{inst.id},
+				),
+			}
+
+			// Use a different context to cancel the command so that Wash
+			// will still attempt to cancel it when our current context is
+			// cancelled (i.e. when reason == ctx.Err().Error()).
+			cancelCtx, cancelFunc := context.WithTimeout(context.Background(), 3*time.Second)
+			defer cancelFunc()
+
+			// CancelCommand's response doesn't contain any useful information,
+			// so we can discard it.
+			_, err := inst.ssmClient.CancelCommandWithContext(cancelCtx, request)
+			if err != nil {
+				activity.Record(ctx, "Failed to cancel the command: %v", err)
+			} else {
+				activity.Record(ctx, "Successfully cancelled the command")
+			}
+		}
+
+		// According to https://github.com/aws/amazon-ssm-agent/blob/2.3.479.0/agent/agentlogstocloudwatch/cloudwatchlogspublisher/cloudwatchlogsservice.go#L40,
+		// SSM updates the stdout/stderr logs every three seconds,
+		// so we'll sleep a little bit more than that between each
+		// iteration to account for delays
+		sleepDuration := 4 * time.Second
+
+		for {
+			time.Sleep(sleepDuration)
+
+			activity.Record(ctx, "Getting the command status")
+			request := &ssm.GetCommandInvocationInput{
+				CommandId:  awsSDK.String(commandID),
+				InstanceId: awsSDK.String(inst.id),
+			}
+
+			resp, err := inst.ssmClient.GetCommandInvocationWithContext(ctx, request)
+			if err != nil {
+				err := fmt.Errorf("failed to get the command status: %v", err)
+				cancelCommand(err.Error())
+				closeOutputStreamer(err)
+				return
+			}
+
+			status := awsSDK.StringValue(resp.StatusDetails)
+			activity.Record(ctx, "Command status: %v", status)
+
+			switch status {
+			case CommandPending, CommandDelayed:
+				continue
+			case CommandInProgress:
+				if _, err := outputStreamer.sendNextChunks(); err != nil {
+					cancelCommand(err.Error())
+					closeOutputStreamer(err)
+					return
+				}
+			case CommandSuccess, CommandFailed:
+				exitCode = int(awsSDK.Int64Value(resp.ResponseCode))
+
+				// Write the remaining output chunks (if any)
+				activity.Record(ctx, "Command finished. Sending over its remaining output...")
+				for {
+					chunksWereSent, err := outputStreamer.sendNextChunks()
+					if err != nil {
+						activity.Record(ctx, "Failed to send over the remaining output: %v", err)
+						closeOutputStreamer(err)
+						return
+					}
+
+					if chunksWereSent {
+						time.Sleep(sleepDuration)
+					} else {
+						// No new chunks were written. Assume that this means
+						// we've finished writing the output
+						activity.Record(ctx, "Finished sending the output.")
+						closeOutputStreamer(nil)
+						return
+					}
+				}
+			case CommandExecutionTimedOut:
+				closeOutputStreamer(fmt.Errorf("the command timed out during its execution"))
+				return
+			default:
+				closeOutputStreamer(fmt.Errorf("the command failed to start. Its status is %v", status))
+				return
+			}
+		}
+	}()
+
+	execResult.OutputCh = outputCh
+	execResult.ExitCodeCB = func() (int, error) {
+		if exitCodeErr != nil {
+			return 0, exitCodeErr
+		}
+
+		return exitCode, nil
 	}
 
-	// TODO: scrape default user and authorized keys from console output. Probably only works for Amazon AMIs.
-	// See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connection-prereqs.html#connection-prereqs-fingerprint
-	// for some helpful defaults we could add.
-	var hostname string
-	if name, ok := meta["PublicDnsName"]; ok {
-		hostname = name.(string)
-	} else if ipaddr, ok := meta["PublicIpAddress"]; ok {
-		hostname = ipaddr.(string)
+	return execResult, nil
+}
+
+// This streamer fetches new CloudWatch log events from the stdout/stderr logs,
+// and writes them to the respective stdout/stderr stream in timestamp order.
+type outputStreamer struct {
+	ctx             context.Context
+	stdout          *plugin.OutputStream
+	stdoutLogStream *outputLogStream
+	stderr          *plugin.OutputStream
+	stderrLogStream *outputLogStream
+}
+
+func newOutputStreamer(
+	ctx context.Context,
+	client *cloudwatchlogs.CloudWatchLogs,
+	commandID string,
+	instanceID string,
+) (<-chan plugin.ExecOutputChunk, *outputStreamer) {
+	s := &outputStreamer{
+		ctx:             ctx,
+		stdoutLogStream: newOutputLogStream("stdout", client, commandID, instanceID),
+		stderrLogStream: newOutputLogStream("stderr", client, commandID, instanceID),
+	}
+
+	outputCh, stdout, stderr := plugin.CreateExecOutputStreams(ctx)
+	s.stdout = stdout
+	s.stderr = stderr
+
+	return outputCh, s
+}
+
+func (s *outputStreamer) sendNextChunks() (bool, error) {
+	activity.Record(s.ctx, "Attempting to send the next set of output chunks to stdout and stderr")
+
+	// Unfortunately, we can't use FilterLogEvents to get this info in a single
+	// stream because AWS gives no guarantee that the interleaved events will be
+	// properly sorted.
+	//
+	// NOTE: According to https://docs.aws.amazon.com/cli/latest/reference/logs/get-log-events.html,
+	// the largest each response can be is 1 MB or 10000 events, so worst-case we will
+	// get 2 MB of information when fetching stdout/stderr events. Probably not a big
+	// deal right now, but maybe an issue in the future if/when we do a parallel exec.
+	stdoutEvents, err := s.stdoutLogStream.nextEvents(s.ctx)
+	if err != nil {
+		return false, err
+	}
+
+	stderrEvents, err := s.stderrLogStream.nextEvents(s.ctx)
+	if err != nil {
+		return false, err
+	}
+
+	if len(stdoutEvents) == 0 && len(stderrEvents) == 0 {
+		activity.Record(s.ctx, "Did not send any chunks: the stdout/stderr log streams did not have any new events")
+		return false, nil
+	}
+
+	// We have some output chunks that we can send. Start by iterating over
+	// stdout/stderr events, sending the earliest event first. Keep doing
+	// this until we run out of stdout OR stderr events.
+	stdoutIx := 0
+	stderrIx := 0
+	for {
+		if stdoutIx >= len(stdoutEvents) || stderrIx >= len(stderrEvents) {
+			break
+		}
+
+		stdoutEvent := stdoutEvents[stdoutIx]
+		stderrEvent := stderrEvents[stderrIx]
+		if timestampOf(stdoutEvent).Before(timestampOf(stderrEvent)) {
+			if err := sendEvent(stdoutEvent, s.stdout); err != nil {
+				return false, err
+			}
+
+			stdoutIx++
+		} else {
+			if err := sendEvent(stderrEvent, s.stderr); err != nil {
+				return false, err
+			}
+
+			stderrIx++
+		}
+	}
+
+	// Here, at least one of stdoutEvents/stderrEvents is
+	// empty.
+	if stdoutIx >= len(stdoutEvents) {
+		// Send any remaining stderr events,
+		for ; stderrIx < len(stderrEvents); stderrIx++ {
+			if err := sendEvent(stderrEvents[stderrIx], s.stderr); err != nil {
+				return false, err
+			}
+		}
 	} else {
-		return plugin.ExecResult{}, fmt.Errorf("No public interface found for %v", inst)
+		// Send any remaining stdout events
+		for ; stdoutIx < len(stdoutEvents); stdoutIx++ {
+			if err := sendEvent(stdoutEvents[stdoutIx], s.stdout); err != nil {
+				return false, err
+			}
+		}
 	}
 
-	// Use the default user for Amazon AMIs. See above for ideas on making this more general. Can be
-	// overridden in ~/.ssh/config.
-	return transport.ExecSSH(ctx, transport.Identity{Host: hostname, User: "ec2-user"}, append([]string{cmd}, args...), opts)
+	return true, nil
+}
+
+func (s *outputStreamer) CloseWithError(err error) {
+	s.stdout.CloseWithError(err)
+	s.stderr.CloseWithError(err)
+	s.stdoutLogStream.Close(s.ctx)
+	s.stderrLogStream.Close(s.ctx)
+}
+
+func timestampOf(event *cloudwatchlogs.OutputLogEvent) time.Time {
+	return time.Unix(0, int64(time.Millisecond)*awsSDK.Int64Value(event.Timestamp))
+}
+
+func sendEvent(e *cloudwatchlogs.OutputLogEvent, stream *plugin.OutputStream) error {
+	timestamp := timestampOf(e)
+	// SSM sends the messages line-by line; however, it does not append
+	// the newline character to the last message in the event.
+	//
+	// NOTE: Since SSM uses bufio.Scanner to parse each message, there's a
+	// chance that the printed stdout/stderr stream can end in an extra
+	// newline character. Unfortunately, we don't have an easy way of determining
+	// whether the last message did end in a new line, so let this be a known
+	// issue until there's a good reason for us to address it.
+	message := awsSDK.StringValue(e.Message) + "\n"
+
+	return stream.WriteWithTimestamp(timestamp, []byte(message))
+}
+
+type outputLogStream struct {
+	name          string
+	created       bool
+	nextToken     string
+	client        *cloudwatchlogs.CloudWatchLogs
+	logGroupName  string
+	logStreamName string
+}
+
+func newOutputLogStream(
+	name string,
+	client *cloudwatchlogs.CloudWatchLogs,
+	commandID string,
+	instanceID string,
+) *outputLogStream {
+	return &outputLogStream{
+		name:         name,
+		created:      false,
+		nextToken:    "",
+		client:       client,
+		logGroupName: "/aws/ssm/AWS-RunShellScript",
+		logStreamName: fmt.Sprintf(
+			"%v/%v/aws-runShellScript/%v",
+			commandID,
+			instanceID,
+			name,
+		),
+	}
+}
+
+func (s *outputLogStream) nextEvents(ctx context.Context) ([]*cloudwatchlogs.OutputLogEvent, error) {
+	activity.Record(
+		ctx,
+		"Fetching the next CloudWatch log events for %v. Log group name: %v. Log stream name: %v",
+		s.name,
+		s.logGroupName,
+		s.logStreamName,
+	)
+
+	request := &cloudwatchlogs.GetLogEventsInput{
+		LogGroupName:  awsSDK.String(s.logGroupName),
+		LogStreamName: awsSDK.String(s.logStreamName),
+	}
+	if s.nextToken == "" {
+		// This is our first request
+		request.StartFromHead = awsSDK.Bool(true)
+	} else {
+		request.NextToken = awsSDK.String(s.nextToken)
+	}
+
+	resp, err := s.client.GetLogEventsWithContext(ctx, request)
+	if err != nil {
+		if awserr, ok := err.(awserr.Error); ok {
+			if awserr.Code() == cloudwatchlogs.ErrCodeResourceNotFoundException {
+				if s.created {
+					return nil, fmt.Errorf("the CloudWatch logs for %v were unexpectedly deleted", s.name)
+				}
+
+				// The stream hasn't been created. This is OK. It means that
+				// AWS hasn't sent them over yet, OR the command hasn't printed
+				// anything to this stream.
+				activity.Record(
+					ctx,
+					"Did not fetch any events for %v: the log stream hasn't been created yet",
+					s.name,
+				)
+
+				return []*cloudwatchlogs.OutputLogEvent{}, nil
+			}
+		}
+
+		return nil, fmt.Errorf("failed to fetch the events for %v: %v", s.name, err)
+	}
+	s.created = true
+	s.nextToken = awsSDK.StringValue(resp.NextForwardToken)
+
+	activity.Record(ctx, "Fetched %v new events for %v", len(resp.Events), s.name)
+	return resp.Events, nil
+}
+
+func (s *outputLogStream) Close(ctx context.Context) {
+	if !s.created {
+		return
+	}
+
+	activity.Record(
+		ctx,
+		"Deleting the logs for %v. Log group name: %v. Log stream name: %v",
+		s.name,
+		s.logGroupName,
+		s.logStreamName,
+	)
+
+	request := &cloudwatchlogs.DeleteLogStreamInput{
+		LogGroupName:  awsSDK.String(s.logGroupName),
+		LogStreamName: awsSDK.String(s.logStreamName),
+	}
+
+	// Use a different context to delete the log stream so that Wash
+	// will still attempt to delete it when our current context is
+	// cancelled
+	deleteCtx, cancelFunc := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancelFunc()
+
+	// DeleteLogStream's response does not contain any useful information
+	// so we can discard it
+	_, err := s.client.DeleteLogStreamWithContext(deleteCtx, request)
+	if err != nil {
+		activity.Record(ctx, "Failed to delete the logs for %v: %v", s.name, err)
+	}
 }
diff --git a/plugin/docker/container.go b/plugin/docker/container.go
index b577c2c..6c5336b 100644
--- a/plugin/docker/container.go
+++ b/plugin/docker/container.go
@@ -76,17 +76,29 @@ func (c *container) Exec(ctx context.Context, cmd string, args []string, opts pl
 		return execResult, err
 	}
 
-	// Asynchronously copy container exec output to an exec output channel.
 	outputCh, stdout, stderr := plugin.CreateExecOutputStreams(ctx)
 	go func() {
+		go func() {
+			// Close the response on context cancellation. Copying will block until there's more to read
+			// from the exec output. For an action with no more output it may never return.
+			// Asynchronously watch for context cancellation, and when it happens close the response so
+			// the parent goroutine can complete. We expect all contexts to complete eventually.
+			<-ctx.Done()
+
+			if opts.Tty {
+				// If resp.Conn is still open, Send Ctrl-C over resp.Conn before closing it.
+				_, err := resp.Conn.Write([]byte{0x03})
+				activity.Record(ctx, "Sent ETX on context termination: %v", err)
+			}
+			resp.Close()
+		}()
+
 		_, err := stdcopy.StdCopy(stdout, stderr, resp.Reader)
 		activity.Record(ctx, "Exec on %v complete: %v", c.Name(), err)
 		stdout.CloseWithError(err)
 		stderr.CloseWithError(err)
-		resp.Close()
 	}()
 
-	// If stdin is supplied, asynchronously copy it to container exec input.
 	var writeErr error
 	if opts.Stdin != nil {
 		go func() {
@@ -102,16 +114,6 @@ func (c *container) Exec(ctx context.Context, cmd string, args []string, opts pl
 	}
 
 	execResult.OutputCh = outputCh
-	execResult.CancelFunc = func() {
-		// Close the response on cancellation. Copying will block until there's more to read from the
-		// exec output. For an action with no more output it may never return.
-		if opts.Tty {
-			// If resp.Conn is still open, send Ctrl-C over resp.Conn before closing it.
-			_, err := resp.Conn.Write([]byte{0x03})
-			activity.Record(ctx, "Sent ETX on context termination: %v", err)
-		}
-		resp.Close()
-	}
 	execResult.ExitCodeCB = func() (int, error) {
 		if writeErr != nil {
 			return 0, err
diff --git a/plugin/kubernetes/pod.go b/plugin/kubernetes/pod.go
index 452a02b..9cbb54f 100644
--- a/plugin/kubernetes/pod.go
+++ b/plugin/kubernetes/pod.go
@@ -146,29 +146,33 @@ func (p *pod) Exec(ctx context.Context, cmd string, args []string, opts plugin.E
 
 	outputCh, stdout, stderr := plugin.CreateExecOutputStreams(ctx)
 	exitcode := 0
-
-	// If using a Tty, create an input stream that allows us to send Ctrl-C to end execution;
-	// when a Tty is allocated commands expect user input and will respond to control signals.
-	stdin := opts.Stdin
-	if opts.Tty {
-		r, w := io.Pipe()
-		if stdin != nil {
-			stdin = io.MultiReader(stdin, r)
-		} else {
-			stdin = r
-		}
-
-		execResult.CancelFunc = func() {
-			// Close the response on context cancellation. Copying will block until there's more to
-			// read from the exec output. For an action with no more output it may never return.
-			// Append Ctrl-C to input to signal end of execution.
-			_, err := w.Write([]byte{0x03})
-			activity.Record(ctx, "Sent ETX on context termination: %v", err)
-			w.Close()
+	go func() {
+		stdin := opts.Stdin
+		if opts.Tty {
+			// If using a Tty, create an input stream that allows us to send Ctrl-C to end execution;
+			// when a Tty is allocated commands expect user input and will respond to control signals.
+			// Otherwise close input now to ensure commands that depend on EOF execute correctly.
+			r, w := io.Pipe()
+			if stdin != nil {
+				stdin = io.MultiReader(stdin, r)
+			} else {
+				stdin = r
+			}
+
+			go func() {
+				// Close the response on context cancellation. Copying will block until there's more to
+				// read from the exec output. For an action with no more output it may never return.
+				// Asynchronously watch for context cancellation, and when it happens close the response
+				// so the parent goroutine can complete. We expect all contexts to complete eventually.
+				<-ctx.Done()
+
+				// Append Ctrl-C to input to signal end of execution.
+				_, err := w.Write([]byte{0x03})
+				activity.Record(ctx, "Sent ETX on context termination: %v", err)
+				w.Close()
+			}()
 		}
-	}
 
-	go func() {
 		streamOpts := remotecommand.StreamOptions{Stdout: stdout, Stderr: stderr, Stdin: stdin, Tty: opts.Tty}
 		err = executor.Stream(streamOpts)
 		activity.Record(ctx, "Exec on %v complete: %v", p.Name(), err)
diff --git a/plugin/types.go b/plugin/types.go
index 22884e1..b3266fa 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -82,9 +82,6 @@ type ExecOptions struct {
 	// tail -f), because it ensures that there are no orphaned processes after the request is
 	// cancelled/finished.
 	Tty bool
-
-	// Elevate execution to run as a privileged user if not already running as a privileged user.
-	Elevate bool
 }
 
 // ExecPacketType identifies the packet type.
@@ -105,18 +102,10 @@ type ExecOutputChunk struct {
 }
 
 // ExecResult is a struct that contains the result of invoking Execable#exec.
-// Any of these fields can be nil.
-//
-// OutputCh: contains timestamped chunks of the running command's stdout/stderr. This should be set
-//           to the channel that's returned by plugin.CreateExecOutputStreams(). OutputCh must be
-//           closed to signal that execution is complete.
-// ExitCodeCB: is called after execution completes to determine final status of execution.
-// CancelFunc: cancels a running command. It should noop for a finished command. CancelFunc is
-//             called when the execution context completes to perform necessary termination.
+// Any of these fields can be nil. The OutputCh will be closed when execution completes.
 type ExecResult struct {
 	OutputCh   <-chan ExecOutputChunk
 	ExitCodeCB func() (int, error)
-	CancelFunc func()
 }
 
 // Execable is an entry that can have a command run on it.
diff --git a/transport/ssh.go b/transport/ssh.go
deleted file mode 100644
index 561e2c5..0000000
--- a/transport/ssh.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package transport
-
-import (
-	"context"
-	"fmt"
-	"net"
-	"os"
-	"os/user"
-	"path/filepath"
-	"time"
-
-	"github.com/kballard/go-shellquote"
-	"github.com/kevinburke/ssh_config"
-	"github.com/puppetlabs/wash/activity"
-	"github.com/puppetlabs/wash/datastore"
-	"github.com/puppetlabs/wash/plugin"
-	"golang.org/x/crypto/ssh"
-	"golang.org/x/crypto/ssh/agent"
-	"golang.org/x/crypto/ssh/knownhosts"
-)
-
-// Cache SSH connections for better performance. Re-using SSH connections can significantly speed
-// up repeated SSH operations.
-var connectionCache = datastore.NewMemCacheWithEvicted(closeConnection)
-var expires = 15 * time.Second
-
-func closeConnection(id string, obj interface{}) {
-	if client, ok := obj.(*ssh.Client); ok {
-		client.Close()
-	}
-}
-
-func homeDir() (string, error) {
-	curUser, err := user.Current()
-	if err != nil {
-		return "", err
-	}
-
-	if curUser.HomeDir == "" {
-		return "", fmt.Errorf("the current user %v does not have a home directory", curUser.Name)
-	}
-	return curUser.HomeDir, nil
-}
-
-func newAgent() (ssh.AuthMethod, error) {
-	sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
-
-	if err != nil {
-		return nil, err
-	}
-	return ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers), nil
-}
-
-func getHostKeyCallback() (ssh.HostKeyCallback, error) {
-	homedir, err := homeDir()
-	if err != nil {
-		return nil, err
-	}
-
-	return knownhosts.New(filepath.Join(homedir, ".ssh", "known_hosts"))
-}
-
-func sshConnect(host, port, user string, strictHostKeyChecking bool) (*ssh.Client, error) {
-	connID := user + "@" + host + ":" + port
-	// This is a single-use cache, so pass in an empty category.
-	obj, err := connectionCache.GetOrUpdate("", connID, expires, true, func() (interface{}, error) {
-		agent, err := newAgent()
-		if err != nil {
-			return nil, fmt.Errorf("Failed to find config from SSH_AUTH_SOCK: %v", err)
-		}
-
-		hostKeyCallback := ssh.InsecureIgnoreHostKey()
-		if strictHostKeyChecking {
-			hostKeyCallback, err = getHostKeyCallback()
-			if err != nil {
-				return nil, fmt.Errorf("Loading SSH known hosts file: %v", err)
-			}
-		}
-
-		sshConfig := &ssh.ClientConfig{
-			User:            user,
-			Auth:            []ssh.AuthMethod{agent},
-			HostKeyCallback: hostKeyCallback,
-		}
-
-		return ssh.Dial("tcp", host+":"+port, sshConfig)
-	})
-
-	if err != nil {
-		return nil, err
-	}
-	return obj.(*ssh.Client), nil
-}
-
-// Identity identifies how to connect to a target.
-type Identity struct {
-	Host, User string
-}
-
-// ExecSSH executes against a target via SSH. It will look up port, user, and other configuration
-// by exact hostname match from default SSH config files. Identity can be used to override the
-// default user. If opts.Elevate is true, will attempt to `sudo` as root.
-//
-// Lots of SSH configuration is currently omitted, such as global known hosts files, finding known
-// hosts from the config, identity file from config... pretty much everything but port and user
-// from config as enumerated in https://github.com/kevinburke/ssh_config/blob/0.5/validators.go.
-//
-// The known hosts file will be ignored if StrictHostKeyChecking=no, such as in
-// ```
-// Host *.compute.amazonaws.com
-//   StrictHostKeyChecking no
-// ```
-func ExecSSH(ctx context.Context, id Identity, cmd []string, opts plugin.ExecOptions) (plugin.ExecResult, error) {
-	var result plugin.ExecResult
-
-	// find port, username, etc from .ssh/config
-	port, err := ssh_config.GetStrict(id.Host, "Port")
-	if err != nil {
-		return result, err
-	}
-
-	user := id.User
-	if user == "" {
-		if user, err = ssh_config.GetStrict(id.Host, "User"); err != nil {
-			return result, err
-		}
-	}
-
-	if user == "" {
-		user = "root"
-	}
-
-	strictHostKeyChecking, err := ssh_config.GetStrict(id.Host, "StrictHostKeyChecking")
-	if err != nil {
-		return result, err
-	}
-
-	connection, err := sshConnect(id.Host, port, user, strictHostKeyChecking != "no")
-	if err != nil {
-		return result, fmt.Errorf("Failed to connect: %s", err)
-	}
-
-	// Run command via session
-	session, err := connection.NewSession()
-	if err != nil {
-		return result, fmt.Errorf("Failed to create session: %s", err)
-	}
-
-	if opts.Tty {
-		// sshd only processes signal codes if a TTY has been allocated. So set one up when requested.
-		modes := ssh.TerminalModes{ssh.ECHO: 0, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400}
-		if err := session.RequestPty("xterm", 40, 80, modes); err != nil {
-			return result, fmt.Errorf("Unable to setup a TTY: %v", err)
-		}
-	}
-
-	outputch, stdout, stderr := plugin.CreateExecOutputStreams(ctx)
-	session.Stdin, session.Stdout, session.Stderr = opts.Stdin, stdout, stderr
-
-	if opts.Elevate {
-		cmd = append([]string{"sudo"}, cmd...)
-	}
-
-	cmdStr := shellquote.Join(cmd...)
-	if err := session.Start(cmdStr); err != nil {
-		return result, err
-	}
-
-	// Wait for session to complete and stash result. This is done asynchronously because the
-	// exitcode callback won't be called until stdout/stderr are closed, but they're not closed by
-	// session.Start.
-	var exitErr error
-	go func() {
-		exitErr = session.Wait()
-		activity.Record(ctx, "Closing session for %v: %v", id.Host, session.Close())
-		stdout.Close()
-		stderr.Close()
-	}()
-
-	result.OutputCh = outputch
-	result.CancelFunc = func() {
-		// Close the session on context cancellation. Copying will block until there's more to read
-		// from the exec output. For an action with no more output it may never return.
-		// If a TTY is setup and the session is still open, send Ctrl-C over before closing it.
-		if opts.Tty {
-			activity.Record(ctx, "Sent SIGINT on context termination: %v", session.Signal(ssh.SIGINT))
-		}
-		activity.Record(ctx, "Closing session on context termination for %v: %v", id.Host, session.Close())
-	}
-	result.ExitCodeCB = func() (int, error) {
-		if exitErr == nil {
-			return 0, nil
-		} else if err, ok := exitErr.(*ssh.ExitError); ok {
-			return err.ExitStatus(), nil
-		}
-		return 0, exitErr
-	}
-	return result, nil
-}
diff --git a/volume/fs.go b/volume/fs.go
index a36cbed..01c733d 100644
--- a/volume/fs.go
+++ b/volume/fs.go
@@ -31,10 +31,8 @@ func (d *FS) List(ctx context.Context) ([]plugin.Entry, error) {
 	return List(ctx, d, "")
 }
 
-var errNonZero = fmt.Errorf("Exec exited non-zero")
-
 func exec(ctx context.Context, executor plugin.Execable, cmdline []string) (*bytes.Buffer, error) {
-	result, err := executor.Exec(ctx, cmdline[0], cmdline[1:], plugin.ExecOptions{Elevate: true})
+	result, err := executor.Exec(ctx, cmdline[0], cmdline[1:], plugin.ExecOptions{})
 	if err != nil {
 		return nil, err
 	}
@@ -60,8 +58,7 @@ func exec(ctx context.Context, executor plugin.Execable, cmdline []string) (*byt
 	if err != nil {
 		return nil, err
 	} else if exitcode != 0 {
-		// Can happen due to permission denied. Leave handling up to the caller.
-		return &buf, errNonZero
+		return nil, fmt.Errorf("exec exited non-zero")
 	}
 	return &buf, nil
 }
@@ -71,10 +68,7 @@ func (d *FS) VolumeList(ctx context.Context) (DirMap, error) {
 	cmdline := StatCmd("/var/log")
 	activity.Record(ctx, "Running %v on %v", cmdline, plugin.ID(d.executor))
 	buf, err := exec(ctx, d.executor, cmdline)
-	if err == errNonZero {
-		// May not have access to some files, but list the rest.
-		activity.Record(ctx, "%v running %v, attempting to parse output", err, cmdline)
-	} else if err != nil {
+	if err != nil {
 		activity.Record(ctx, "Exec error running %v in VolumeList: %v", cmdline, err)
 		return nil, err
 	}
@@ -96,21 +90,12 @@ func (d *FS) VolumeOpen(ctx context.Context, path string) (plugin.SizedReader, e
 // VolumeStream satisfies the Interface required by List to stream file contents.
 func (d *FS) VolumeStream(ctx context.Context, path string) (io.ReadCloser, error) {
 	activity.Record(ctx, "Streaming %v on %v", path, plugin.ID(d.executor))
-	execOpts := plugin.ExecOptions{Elevate: true, Tty: true}
-	result, err := d.executor.Exec(ctx, "tail", []string{"-f", path}, execOpts)
+	result, err := d.executor.Exec(ctx, "tail", []string{"-f", path}, plugin.ExecOptions{Tty: true})
 	if err != nil {
 		activity.Record(ctx, "Exec error in VolumeRead: %v", err)
 		return nil, err
 	}
 
-	// Setup context cancellation handling
-	if result.CancelFunc != nil {
-		go func() {
-			<-ctx.Done()
-			result.CancelFunc()
-		}()
-	}
-
 	r, w := io.Pipe()
 	go func() {
 		// Exec uses context; if it's canceled, the OutputCh will close. Close the writer.
diff --git a/website/content/_index.md b/website/content/_index.md
index 74d6b9e..251ee6e 100644
--- a/website/content/_index.md
+++ b/website/content/_index.md
@@ -7,7 +7,7 @@ draft= false
 
 `wash` helps you deal with all your remote or cloud-native infrastructure using the UNIX-y patterns and tools you already know and love.
 
-<script id="asciicast-245046" src="https://asciinema.org/a/245046.js" async></script>
+_TBD: Screencast_
 
 `wash` aims to:
 


Test patch:
diff --git a/cmd/internal/find/parser/parseExpression_test.go b/cmd/internal/find/parser/parseExpression_test.go
index 0ca2d49..ea72e3b 100644
--- a/cmd/internal/find/parser/parseExpression_test.go
+++ b/cmd/internal/find/parser/parseExpression_test.go
@@ -3,8 +3,10 @@ package parser
 import (
 	"regexp"
 	"testing"
+	"time"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/types"
 	"github.com/stretchr/testify/suite"
 )
@@ -17,6 +19,14 @@ type ParseExpressionTestSuite struct {
 	parsertest.Suite
 }
 
+func (s *ParseExpressionTestSuite) SetupTest() {
+	params.StartTime = time.Now()
+}
+
+func (s *ParseExpressionTestSuite) TeardownTest() {
+	params.StartTime = time.Time{}
+}
+
 func (s *ParseExpressionTestSuite) NPETC(input string, errMsg string) parsertest.Case {
 	return s.Suite.NPETC(input, regexp.QuoteMeta(errMsg), false)
 }
@@ -69,16 +79,23 @@ func (s *ParseExpressionTestSuite) TestParseExpressionBinOpParseErrors() {
 
 func (s *ParseExpressionTestSuite) TestParseExpressionAndOpEval() {
 	s.RunTestCases(
+		s.NPTC("-false -a -false", false),
+		s.NPTC("-false -false", false),
+		s.NPTC("-false -a -true", false),
+		s.NPTC("-false -true", false),
 		s.NPTC("-true -a -false", false),
 		s.NPTC("-true -false", false),
+		s.NPTC("-true -a -true", true),
 		s.NPTC("-true -true", true),
 	)
 }
 
 func (s *ParseExpressionTestSuite) TestParseExpressionOrOpEval() {
 	s.RunTestCases(
-		s.NPTC("-true -o -false", true),
+		s.NPTC("-false -o -false", false),
 		s.NPTC("-false -o -true", true),
+		s.NPTC("-true -o -false", true),
+		s.NPTC("-true -o -true", true),
 	)
 }
 
@@ -129,16 +146,29 @@ func (s *ParseExpressionTestSuite) TestParseExpressionComplexErrors() {
 	s.RunTestCases(
 		s.NPETC("( -true ) -a )", "): no beginning '('"),
 		s.NPETC("-true -a -foo", "-foo: unknown primary or operator"),
+		// Make sure meta primary expressions are parsed independently of
+		// the top-level `wash find` expression
+		s.NPETC("-m .key (", "-m: (: missing closing ')'"),
 	)
 }
 
 func (s *ParseExpressionTestSuite) TestParseExpressionComplexEval() {
+	// Set-up the entry for the meta primary integration test
+	m := make(map[string]interface{})
+	m["key"] = "foo"
+	entry := types.Entry{}
+	entry.Attributes.SetMeta(m)
+
 	s.RunTestCases(
 		s.NPTC("( -true -o -true ) -false", false),
 		// Should be parsed as (-true -a -false) -o -true which evaluates to true.
 		s.NPTC("-true -false -o -true", true),
 		s.NPTC("-false -o -true -false", false),
 		s.NPTC("( -true -true ) -o -false", true),
+		// Test meta primary integration. Use s.Suite.NPTC/s.Suite.NPNTC because
+		// we're providing our own entry
+		s.Suite.NPTC("-m .key foo -o -m .key bar", "", entry),
+		s.Suite.NPNTC("-m .key foo -a -m .key bar", "", entry),
 	)
 }
 
diff --git a/cmd/internal/find/primary/meta/arrayPredicate_test.go b/cmd/internal/find/primary/meta/arrayPredicate_test.go
index d5f9a33..de31ed6 100644
--- a/cmd/internal/find/primary/meta/arrayPredicate_test.go
+++ b/cmd/internal/find/primary/meta/arrayPredicate_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -84,48 +85,69 @@ func (s *ArrayPredicateTestSuite) TestParseArrayPredicateType() {
 func (s *ArrayPredicateTestSuite) TestArrayPSome() {
 	p := arrayP(arrayPredicateType{t: 's'}, trueP)
 
-	// Returns false for a non-array value
-	s.False(p("foo"))
+	// Returns false for a non-array value. Not(p) should also
+	// return false.
+	s.False(p.IsSatisfiedBy("foo"))
+	s.False(p.Negate().IsSatisfiedBy("foo"))
 
-	// Returns false if no elements satisfy p
-	s.False(p(toA(false)))
+	// Returns false if no elements satisfy p. Not(p) should return
+	// true here.
+	s.False(p.IsSatisfiedBy(toA(false, false)))
+	s.True(p.Negate().IsSatisfiedBy(toA(false, false)))
 
-	// Returns true if some element satifies p
-	s.True(p(toA(true)))
-	s.True(p(toA("foo", true)))
+	// Returns true if some element satifies . Not(p) should return
+	// false here.
+	s.True(p.IsSatisfiedBy(toA(true, false)))
+	s.False(p.Negate().IsSatisfiedBy(toA(true, false)))
 }
 
 func (s *ArrayPredicateTestSuite) TestArrayPAll() {
 	p := arrayP(arrayPredicateType{t: 'a'}, trueP)
 
-	// Returns false for a non-array value
-	s.False(p("foo"))
-
-	// Returns false if no elements satisfy p
-	s.False(p(toA(false)))
-
-	// Returns false if only some of the elements satisfy p
-	s.False(p(toA(false, true)))
-
-	// Returns true if all the elements satisfy P
-	s.True(p(toA(true)))
-	s.True(p(toA(true, true)))
+	// Returns false for a non-array value. Not(p) should also
+	// return false.
+	s.False(p.IsSatisfiedBy("foo"))
+	s.False(p.Negate().IsSatisfiedBy("foo"))
+
+	// Returns false if no elements satisfy p. Not(p) should return
+	// true here.
+	s.False(p.IsSatisfiedBy(toA(false)))
+	s.True(p.Negate().IsSatisfiedBy(toA(false)))
+
+	// Returns false if only some of the elements satisfy p. Not(p) should
+	// return true here
+	s.False(p.IsSatisfiedBy(toA(false, true)))
+	s.True(p.Negate().IsSatisfiedBy(toA(false, true)))
+
+	// Returns true if all the elements satisfy P. Not(p) should return
+	// false here.
+	s.True(p.IsSatisfiedBy(toA(true)))
+	s.False(p.Negate().IsSatisfiedBy(toA(true)))
+	s.True(p.IsSatisfiedBy(toA(true, true)))
+	s.False(p.Negate().IsSatisfiedBy(toA(true, true)))
 }
 
 func (s *ArrayPredicateTestSuite) TestArrayPNth() {
 	p := arrayP(arrayPredicateType{t: 'n', n: 1}, trueP)
 
-	// Returns false for a non-array value
-	s.False(p("foo"))
+	// Returns false for a non-array value. Not(p) should also
+	// return false.
+	s.False(p.IsSatisfiedBy("foo"))
+	s.False(p.Negate().IsSatisfiedBy("foo"))
 
-	// Returns false if n >= len(vs)
-	s.False(p(toA(true)))
+	// Returns false if n >= len(vs). Not(p) should also return
+	// false.
+	s.False(p.IsSatisfiedBy(toA(true)))
+	s.False(p.Negate().IsSatisfiedBy(toA(true)))
 
-	// Returns false if vs[n] does not satisfy p
-	s.False(p(toA(true, false)))
+	// Returns false if vs[n] does not satisfy p. Not(p) should return
+	// true here.
+	s.False(p.IsSatisfiedBy(toA(true, false)))
+	s.True(p.Negate().IsSatisfiedBy(toA(true, false)))
 
-	// Returns true if vs[n] satisfies p
-	s.True(p(toA(true, true)))
+	// Returns true if vs[n] satisfies p. Not(p) should return false.
+	s.True(p.IsSatisfiedBy(toA(true, true)))
+	s.False(p.Negate().IsSatisfiedBy(toA(true, true)))
 }
 
 func toA(vs ...interface{}) []interface{} {
@@ -134,6 +156,6 @@ func toA(vs ...interface{}) []interface{} {
 
 func TestArrayPredicate(t *testing.T) {
 	s := new(ArrayPredicateTestSuite)
-	s.Parser = predicateParser(parseArrayPredicate)
+	s.Parser = predicate.ToParser(parseArrayPredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/emptyPredicate_test.go b/cmd/internal/find/primary/meta/emptyPredicate_test.go
index e572b74..b0c83f4 100644
--- a/cmd/internal/find/primary/meta/emptyPredicate_test.go
+++ b/cmd/internal/find/primary/meta/emptyPredicate_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -25,25 +26,41 @@ func (s *EmptyPredicateTestSuite) TestValidInput() {
 }
 
 func (s *EmptyPredicateTestSuite) TestEmptyPInvalidType() {
-	s.False(emptyP("foo"))
+	p := emptyP(false)
+	s.False(p.IsSatisfiedBy("foo"))
+	s.False(p.Negate().IsSatisfiedBy("foo"))
 }
 
 func (s *EmptyPredicateTestSuite) TestEmptyPObject() {
 	mp := make(map[string]interface{})
-	s.True(emptyP(mp))
+	p := emptyP(false)
+	
+	// Test empty map
+	s.True(p.IsSatisfiedBy(mp))
+	s.False(p.Negate().IsSatisfiedBy(mp))
+
+	// Test nonempty map
 	mp["foo"] = 1
-	s.False(emptyP(mp))
+	s.False(p.IsSatisfiedBy(mp))
+	s.True(p.Negate().IsSatisfiedBy(mp))
 }
 
 func (s *EmptyPredicateTestSuite) TestEmptyPArray() {
 	a := []interface{}{}
-	s.True(emptyP(a))
+	p := emptyP(false)
+
+	// Test empty array
+	s.True(p.IsSatisfiedBy(a))
+	s.False(p.Negate().IsSatisfiedBy(a))
+
+	// Test nonempty array
 	a = append(a, 1)
-	s.False(emptyP(a))
+	s.False(p.IsSatisfiedBy(a))
+	s.True(p.Negate().IsSatisfiedBy(a))
 }
 
 func TestEmptyPredicate(t *testing.T) {
 	s := new(EmptyPredicateTestSuite)
-	s.Parser = predicateParser(parseEmptyPredicate)
+	s.Parser = predicate.ToParser(parseEmptyPredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/numericPredicate_test.go b/cmd/internal/find/primary/meta/numericPredicate_test.go
index 99895c8..6a8f099 100644
--- a/cmd/internal/find/primary/meta/numericPredicate_test.go
+++ b/cmd/internal/find/primary/meta/numericPredicate_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/puppetlabs/wash/cmd/internal/find/primary/numeric"
 	"github.com/stretchr/testify/suite"
 )
@@ -21,7 +22,6 @@ func (s *NumericPredicateTestSuite) TestErrors() {
 }
 
 func (s *NumericPredicateTestSuite) TestValidInput() {
-	// Test the happy cases first
 	s.RunTestCases(
 		// Test a plain numeric value
 		s.NPTC("200 -size", "-size", float64(200)),
@@ -36,17 +36,31 @@ func (s *NumericPredicateTestSuite) TestValidInput() {
 		s.NPTC("+2G -size", "-size", float64(3*numeric.BytesOf('G'))),
 		s.NPTC("-2G -size", "-size", float64(1*numeric.BytesOf('G'))),
 	)
+}
+
+func (s *NumericPredicateTestSuite) TestNumericP_NotANumber() {
+	np := numericP(func(n int64) bool {
+		return n > 5
+	})
+	s.False(np.IsSatisfiedBy("6"))
+	s.False(np.Negate().IsSatisfiedBy("3"))
+}
+
+func (s *NumericPredicateTestSuite) TestNumericP() {
+	np := numericP(func(n int64) bool {
+		return n > 5
+	})
+
+	s.True(np.IsSatisfiedBy(float64(6)))
+	s.False(np.IsSatisfiedBy(float64(3)))
 
-	// Now test that the predicate returns false for a non-numeric
-	// value (i.e. a non float64 value)
-	p, _, err := parseNumericPredicate(s.ToTks("200"))
-	if s.NoError(err) {
-		s.False(p("200"))
-	}
+	// Test negation
+	s.False(np.Negate().IsSatisfiedBy(float64(6)))
+	s.True(np.Negate().IsSatisfiedBy(float64(3)))
 }
 
 func TestNumericPredicate(t *testing.T) {
 	s := new(NumericPredicateTestSuite)
-	s.Parser = predicateParser(parseNumericPredicate)
+	s.Parser = predicate.ToParser(parseNumericPredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/objectPredicate_test.go b/cmd/internal/find/primary/meta/objectPredicate_test.go
index 8a4b963..955444a 100644
--- a/cmd/internal/find/primary/meta/objectPredicate_test.go
+++ b/cmd/internal/find/primary/meta/objectPredicate_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -28,7 +29,7 @@ func (s *ObjectPredicateTestSuite) TestKeyRegex() {
 	s.NotRegexp(keyRegex, "]key")
 }
 
-func (s *ObjectPredicateTestSuite) TestErrors() {
+func (s *ObjectPredicateTestSuite) TestParseObjectPredicateErrors() {
 	s.RunTestCases(
 		s.NPETC("", "expected a key sequence", true),
 		s.NPETC("foo", "key sequences must begin with a '.'", true),
@@ -41,7 +42,7 @@ func (s *ObjectPredicateTestSuite) TestErrors() {
 	)
 }
 
-func (s *ObjectPredicateTestSuite) TestValidInput() {
+func (s *ObjectPredicateTestSuite) TestParseObjectPredicateValidInput() {
 	// Make the satisfying maps
 	mp1 := make(map[string]interface{})
 	mp1["key"] = true
@@ -67,12 +68,16 @@ func (s *ObjectPredicateTestSuite) TestValidInput() {
 }
 
 func (s *ObjectPredicateTestSuite) TestObjectP_NotAnObject() {
-	s.False(objectP("foo", trueP)("not an object"))
+	objP := objectP("foo", trueP)
+	s.False(objP.IsSatisfiedBy("not an object"))
+	s.False(objP.Negate().IsSatisfiedBy("not an object"))
 }
 
 func (s *ObjectPredicateTestSuite) TestObjectP_NonexistantKey() {
 	mp := make(map[string]interface{})
-	s.False(objectP("foo", trueP)(mp))
+	objP := objectP("foo", trueP)
+	s.False(objP.IsSatisfiedBy(mp))
+	s.False(objP.Negate().IsSatisfiedBy(mp))
 }
 
 func (s *ObjectPredicateTestSuite) TestObjectP_ExistantKey() {
@@ -80,14 +85,20 @@ func (s *ObjectPredicateTestSuite) TestObjectP_ExistantKey() {
 	mp["foo"] = "baz"
 
 	var calledP bool
-	p := func(v interface{}) bool {
+	p := genericPredicate(func(v interface{}) bool {
 		calledP = true
 		s.Equal("baz", v, "objectP did not pass-in mp[key] into p")
 		return true
-	}
+	})
+	objP := objectP("foo", p)
 
-	s.True(objectP("foo", p)(mp), "objectP did not return p(mp[key])")
+	s.True(objP.IsSatisfiedBy(mp), "objectP did not return p(mp[key])")
 	s.True(calledP, "objectP did not invoke p")
+	
+	// Now test negation
+	calledP = false
+	s.False(objP.Negate().IsSatisfiedBy(mp), "objectP.Negate() did not return !p(mp[key])")
+	s.True(calledP, "objectP.Negate() did not invoke p")
 }
 
 func (s *ObjectPredicateTestSuite) TestFindMatchingKey() {
@@ -102,6 +113,6 @@ func (s *ObjectPredicateTestSuite) TestFindMatchingKey() {
 
 func TestObjectPredicate(t *testing.T) {
 	s := new(ObjectPredicateTestSuite)
-	s.Parser = predicateParser(parseObjectPredicate)
+	s.Parser = predicate.ToParser(parseObjectPredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/predicateExpression_test.go b/cmd/internal/find/primary/meta/predicateExpression_test.go
new file mode 100644
index 0000000..03382fe
--- /dev/null
+++ b/cmd/internal/find/primary/meta/predicateExpression_test.go
@@ -0,0 +1,225 @@
+package meta
+
+import (
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/puppetlabs/wash/cmd/internal/find/params"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
+	"github.com/stretchr/testify/suite"
+)
+
+// The predicates are tested separately in their *Predicate.go files, so they
+// will not be tested here. Instead, the tests here serve as "integration tests" for
+// the parsePredicateExpression function. They're meant to test parser errors, each of
+// the operators, and whether operator precedence is enforced. 
+
+type PredicateExpressionTestSuite struct {
+	parsertest.Suite
+}
+
+func (s *PredicateExpressionTestSuite) NPETC(input string, errRegex string) parsertest.Case {
+	return s.Suite.NPETC(input, regexp.QuoteMeta(errRegex), false)
+}
+
+// NPOETC => NewParserOpEvalTestCase. input should only contain Boolean predicates (-true/-false).
+// The Boolean predicates are meant to represent individual predicates that evaluate to -true/-false,
+// respectively. Thus, NPOETC("-false -o -false", "", false) is read as "Test that if p1 returns false,
+// and p2 returns false, then p1 -o p2 returns false". It is _not_ meant to be confused with
+// NPTC("-false -o -false", "", false), which is read as "Test that the parsed predicate returns true if
+// v is false OR is false". In other words, in NPTC, "-false" is a Boolean predicate while in NPOETC,
+// "-false" represents _any_ predicate p (object/array/primitive/...) that returns false. NPOETC is useful
+// to make binary op/parens op tests more expressive.
+func (s *PredicateExpressionTestSuite) NPOETC(input string, remInput string, expectedValue bool) parsertest.Case {
+	// -false/-true will still be parsed as Boolean predicates. Using "true" as the satisfying value in
+	// both cases ensures that -false/-true represent an arbitrary predicate p that returns false/true,
+	// respectively (since -false(true) == false, -true(true) == true).
+	if expectedValue {
+		return s.NPTC(input, remInput, true)
+	}
+	return s.NPNTC(input, remInput, true)
+}
+
+func (s *PredicateExpressionTestSuite) SetupTest() {
+	params.StartTime = time.Now()
+}
+
+func (s *PredicateExpressionTestSuite) TeardownTest() {
+	params.StartTime = time.Time{}
+}
+
+func (s *PredicateExpressionTestSuite) TestEmptyExpression() {
+	s.RunTestCases(
+		s.NPETC("", "expected a predicate expression"),
+		s.NPETC("-primary", "unknown predicate -primary"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestNotOpParseErrors() {
+	s.RunTestCases(
+		// Test error when "-not" is supplied without an expression
+		s.NPETC("-not", "-not: no following expression"),
+		// Test error when "-not" is mixed with parentheses
+		s.NPETC("-not )", "): no beginning '('"),
+		s.NPETC("( -not )", "-not: no following expression"),
+		// Test error when "-not" is supplied w/ a malformed predicate
+		s.NPETC("-not .", "expected a key sequence after '.'"),
+		// Test error when "-not" is followed by a binary operator
+		s.NPETC("-not -a", "-not: no following expression"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestNotOpEval() {
+	s.RunTestCases(
+		s.NPTC("-not -true -primary", "-primary", false),
+		s.NPTC("-not -not -true -primary", "-primary", true),
+		s.NPTC("-not -not -not -true -primary", "-primary", false),
+		// Ensure that "-not <predicate>" and "-not -not <predicate>" always
+		// return false for mis-typed values. It is important to thoroughly
+		// test this behavior because it can be very confusing for users to
+		// debug if something goes wrong with the negation.
+		//
+		// Start with array predicates
+		s.NPNTC("-not [?] -true -primary", "-primary", "foo"),
+		s.NPNTC("-not -not [?] -true -primary", "-primary", "foo"),
+		// empty predicate
+		s.NPNTC("-not -empty -primary", "-primary", "foo"),
+		s.NPNTC("-not -not -empty -primary", "-primary", "foo"),
+		// numeric predicate
+		s.NPNTC("-not +1 -primary", "-primary", "2"),
+		s.NPNTC("-not -not -1 -primary", "-primary", "0"),
+		// object predicate
+		s.NPNTC("-not .key -true -primary", "-primary", "foo"),
+		s.NPNTC("-not -not .key -true -primary", "-primary", "foo"),
+		// boolean predicate
+		s.NPNTC("-not -true -primary", "-primary", "false"),
+		s.NPNTC("-not -not -true -primary", "-primary", "true"),
+		// string predicate	
+		s.NPNTC("-not f -primary", "-primary", 'g'),
+		s.NPNTC("-not -not f -primary", "-primary", 'f'),
+		// time predicate
+		s.NPNTC("-not +1h -primary", "-primary", "not a time"),
+		s.NPNTC("-not -not +1h -primary", "-primary", "not a time"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestBinOpParseErrors() {
+	s.RunTestCases(
+		// Tests for -and
+		s.NPETC("-a", "-a: no expression before -a"),
+		s.NPETC("-true -a", "-a: no expression after -a"),
+		s.NPETC("-true -a -a", "-a: no expression after -a"),
+		// Tests for -or
+		s.NPETC("-o", "-o: no expression before -o"),
+		s.NPETC("-true -o", "-o: no expression after -o"),
+		s.NPETC("-true -o -o", "-o: no expression after -o"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestAndOpEval() {
+	s.RunTestCases(
+		s.NPOETC("-false -a -false -primary", "-primary", false),
+		s.NPOETC("-false -false -primary", "-primary", false),
+		s.NPOETC("-false -a -true -primary", "-primary", false),
+		s.NPOETC("-false -true -primary", "-primary", false),
+		s.NPOETC("-true -a -false -primary", "-primary", false),
+		s.NPOETC("-true -false -primary", "-primary", false),
+		s.NPOETC("-true -a -true -primary", "-primary", true),
+		s.NPOETC("-true -true -primary", "-primary", true),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestOrOpEval() {
+	s.RunTestCases(
+		s.NPTC("-false -o -false -primary", "-primary", false),
+		s.NPTC("-false -o -true -primary", "-primary", true),
+		s.NPTC("-true -o -false -primary", "-primary", true),
+		s.NPTC("-true -o -true -primary", "-primary", true),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestBinOpPrecedence() {
+	s.RunTestCases(
+		// Should be parsed as (-true -o (-true -a -false)), which evaluates to true.
+		// Without precedence, this would be parsed as ((-true -o -true) -a false) which
+		// evaluates to false.
+		s.NPOETC("-true -o -true -a -false -primary", "-primary", true),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestParensErrors() {
+	s.RunTestCases(
+		// Test the simple error cases
+		s.NPETC(")", "): no beginning '('"),
+		s.NPETC("(", "(: missing closing ')'"),
+		s.NPETC("( )", "(): empty inner expression"),
+		// Test some more complicated error cases
+		s.NPETC("( -true ) )", "): no beginning '('"),
+		s.NPETC("( -true ) ( ) -true", "(): empty inner expression"),
+		s.NPETC("( -true ( -false )", "(: missing closing ')'"),
+		s.NPETC("( ( ( -true ) ) ) )", "): no beginning '('"),
+		s.NPETC("( -a )", "-a: no expression before -a"),
+		s.NPETC("( ( ( -true ) -a", "(: missing closing ')'"),
+		s.NPETC("( ( ( -true ) -a ) )", "-a: no expression after -a"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestParensEval() {
+	s.RunTestCases(
+		// Note that w/o the parentheses, this would be parsed as "(-true -o (-true -a -false))"
+		// which would evaluate to true.
+		s.NPOETC("( -true -o -true ) -a -false -primary", "-primary", false),
+		s.NPOETC("-not ( -true -o -false ) -primary", "-primary", false),
+		s.NPOETC("( -true ) -a ( -false ) -primary", "-primary", false),
+		s.NPOETC("( -true ( -false ) -o ( ( -false -true ) ) ) -primary", "-primary", false),
+		s.NPOETC("( ( ( -true ) ) ) -primary", "-primary", true),
+		s.NPOETC("( ( -true ) -a -false ) -primary", "-primary", false),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestComplexErrors() {
+	s.RunTestCases(
+		s.NPETC("( -true ) -a )", "): no beginning '('"),
+		s.NPETC(".key -a -foo", "expected a predicate after key"),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestComplexOpEval() {
+	s.RunTestCases(
+		s.NPOETC("( -true -o -true ) -false -primary", "-primary", false),
+		// Should be parsed as (-true -a -false) -o -true which evaluates to true.
+		s.NPOETC("-true -false -o -true -primary", "-primary", true),
+		s.NPOETC("-false -o -true -false -primary", "-primary", false),
+		s.NPOETC("( -true -true ) -o -false -primary", "-primary", true),
+	)
+}
+
+func (s *PredicateExpressionTestSuite) TestPredicateExpressions() {
+	s.RunTestCases(
+		// Parsed as "> 3 AND > 5"
+		s.NPTC("+3 -a +5 -primary", "-primary", float64(6)),
+		s.NPNTC("+3 -a +5 -primary", "-primary", float64(4)),
+		// Parsed as "(> 3 AND > 5) OR == 1"
+		s.NPTC("( +3 -a +5 ) -o 1 -primary", "-primary", float64(6)),
+		s.NPNTC("+3 -a +5 -primary", "-primary", float64(4)),
+		s.NPTC("( +3 -a +5 ) -o 1 -primary", "-primary", float64(1)),
+		// Parsed as "NOT(> 3) OR > 5" which reduces to "<= 3 OR > 5"
+		s.NPTC("-not +3 -o +5 -primary", "-primary", float64(3)),
+		s.NPTC("-not +3 -o +5 -primary", "-primary", float64(1)),
+		s.NPTC("-not +3 -o +5 -primary", "-primary", float64(6)),
+		// Parsed as "true OR empty object/array"
+		s.NPTC("-true -o -empty -primary", "-primary", true),
+		s.NPTC("-true -o -empty -primary", "-primary", []interface{}{}),
+		// Parsed as "true AND empty object/array" (should always return false)
+		s.NPNTC("-true -a -empty -primary", "-primary", true),
+		s.NPNTC("-true -a -empty -primary", "-primary", []interface{}{}),
+	)
+}
+
+func TestPredicateExpression(t *testing.T) {
+	s := new(PredicateExpressionTestSuite)
+	s.Parser = predicate.ToParser(parsePredicateExpression)
+	suite.Run(t, s)
+}
\ No newline at end of file
diff --git a/cmd/internal/find/primary/meta/predicate_test.go b/cmd/internal/find/primary/meta/predicate_test.go
index b2f4351..dd40c21 100644
--- a/cmd/internal/find/primary/meta/predicate_test.go
+++ b/cmd/internal/find/primary/meta/predicate_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -29,6 +30,16 @@ func (s *PredicateTestSuite) TestErrors() {
 		s.NPETC(".", "expected a key sequence after '.'", false),
 		s.NPETC("[", `expected a closing '\]'`, false),
 		s.NPETC("--15", "positive", false),
+		// These cases ensure that parsePredicate does not parse any expression operators.
+		// Otherwise, parsePredicateExpression may not work correctly.
+		s.NPETC("-a", ".*primitive.*", true),
+		s.NPETC("-and", ".*primitive.*", true),
+		s.NPETC("-o", ".*primitive.*", true),
+		s.NPETC("-or", ".*primitive.*", true),
+		s.NPETC("!", ".*primitive.*", true),
+		s.NPETC("-not", ".*primitive.*", true),
+		s.NPETC("(", ".*primitive.*", true),
+		s.NPETC(")", ".*primitive.*", true),
 	)
 }
 
@@ -47,6 +58,6 @@ func (s *PredicateTestSuite) TestValidInput() {
 
 func TestPredicate(t *testing.T) {
 	s := new(PredicateTestSuite)
-	s.Parser = predicateParser(parsePredicate)
+	s.Parser = predicate.ToParser(parsePredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/primitivePredicate_test.go b/cmd/internal/find/primary/meta/primitivePredicate_test.go
index fd2ef5b..ad772ad 100644
--- a/cmd/internal/find/primary/meta/primitivePredicate_test.go
+++ b/cmd/internal/find/primary/meta/primitivePredicate_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/puppetlabs/wash/cmd/internal/find/primary/numeric"
 	"github.com/stretchr/testify/suite"
 )
@@ -61,19 +62,29 @@ func (s *PrimitivePredicateTestSuite) TestExistsP() {
 }
 
 func (s *PrimitivePredicateTestSuite) TestTrueP() {
-	s.False(trueP("foo"))
-	s.False(trueP(false))
-	s.True(trueP(true))
+	s.False(trueP.IsSatisfiedBy("foo"))
+	s.False(trueP.Negate().IsSatisfiedBy("foo"))
+
+	s.False(trueP.IsSatisfiedBy(false))
+	s.True(trueP.Negate().IsSatisfiedBy(false))
+
+	s.True(trueP.IsSatisfiedBy(true))
+	s.False(trueP.Negate().IsSatisfiedBy(true))
 }
 
 func (s *PrimitivePredicateTestSuite) TestFalseP() {
-	s.False(falseP("foo"))
-	s.False(falseP(true))
-	s.True(falseP(false))
+	s.False(falseP.IsSatisfiedBy("foo"))
+	s.False(falseP.Negate().IsSatisfiedBy("foo"))
+
+	s.False(falseP.IsSatisfiedBy(true))
+	s.True(falseP.Negate().IsSatisfiedBy(true))
+
+	s.True(falseP.IsSatisfiedBy(false))
+	s.False(falseP.Negate().IsSatisfiedBy(false))
 }
 
 func TestPrimitivePredicate(t *testing.T) {
 	s := new(PrimitivePredicateTestSuite)
-	s.Parser = predicateParser(parsePrimitivePredicate)
+	s.Parser = predicate.ToParser(parsePrimitivePredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/stringPredicate_test.go b/cmd/internal/find/primary/meta/stringPredicate_test.go
index 1fd4e8d..091b23d 100644
--- a/cmd/internal/find/primary/meta/stringPredicate_test.go
+++ b/cmd/internal/find/primary/meta/stringPredicate_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/stretchr/testify/suite"
 )
 
@@ -14,7 +15,6 @@ type StringPredicateTestSuite struct {
 func (s *StringPredicateTestSuite) TestErrors() {
 	s.RunTestCases(
 		s.NPETC("", "expected a nonempty string", true),
-		s.NPETC("-a", "-a begins with a '-'", true),
 	)
 
 	_, _, err := parseStringPredicate([]string{""})
@@ -22,22 +22,35 @@ func (s *StringPredicateTestSuite) TestErrors() {
 }
 
 func (s *StringPredicateTestSuite) TestValidInput() {
-	// Test the happy cases first
 	s.RunTestCases(
 		s.NPTC("foo -size", "-size", "foo"),
+		s.NPNTC("foo -size", "-size", "bar"),
 	)
+}
+
+func (s *StringPredicateTestSuite) TestStringP_NotAString() {
+	sp := stringP(func(s string) bool {
+		return s == "f"
+	})
+
+	s.False(sp.IsSatisfiedBy('f'))
+	s.False(sp.Negate().IsSatisfiedBy('g'))
+}
+
+func (s *StringPredicateTestSuite) TestStringP() {
+	sp := stringP(func(s string) bool {
+		return s == "f"
+	})
 
-	// Now test that the predicate returns false for a non-string
-	// value or if value != s
-	p, _, err := parseStringPredicate(s.ToTks("foo"))
-	if s.NoError(err) {
-		s.False(p(200))
-		s.False(p("bar"))
-	}
+	s.True(sp.IsSatisfiedBy("f"))
+	
+	// Test negation
+	s.False(sp.Negate().IsSatisfiedBy("f"))
+	s.True(sp.Negate().IsSatisfiedBy("g"))
 }
 
 func TestStringPredicate(t *testing.T) {
 	s := new(StringPredicateTestSuite)
-	s.Parser = predicateParser(parseStringPredicate)
+	s.Parser = predicate.ToParser(parseStringPredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta/timePredicate_test.go b/cmd/internal/find/primary/meta/timePredicate_test.go
index 57a0a97..2a3d028 100644
--- a/cmd/internal/find/primary/meta/timePredicate_test.go
+++ b/cmd/internal/find/primary/meta/timePredicate_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
 	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/predicate"
 	"github.com/puppetlabs/wash/cmd/internal/find/primary/numeric"
 	"github.com/stretchr/testify/suite"
 )
@@ -56,6 +57,43 @@ func (s *TimePredicateTestSuite) TestValidInputFalseValues() {
 	)
 }
 
+func (s *TimePredicateTestSuite) TestTimeP_Negation_NotATime() {
+	d := 5 * numeric.DurationOf('h')
+	tp := timeP(true, func(n int64) bool {
+		return n > d 
+	})
+	s.False(tp.Negate().IsSatisfiedBy("not_a_valid_time_value"))
+}
+
+func (s *TimePredicateTestSuite) TestTimeP_Negation_TimeMismatch() {
+	d := 5 * numeric.DurationOf('h')
+	// These tests check that negating a timePredicate will still return
+	// false for time mismatches.
+
+	// Test past queries
+	tp := timeP(true, func(n int64) bool {
+		return n > d
+	})
+	s.False(tp.Negate().IsSatisfiedBy(addTST(d+1)))
+	s.False(tp.Negate().IsSatisfiedBy(addTST(d-1)))
+
+	// Test future queries
+	tp = timeP(false, func(n int64) bool {
+		return n > d 
+	})
+	s.False(tp.Negate().IsSatisfiedBy(addTST(-(d+1))))
+	s.False(tp.Negate().IsSatisfiedBy(addTST(-(d-1))))
+}
+
+func (s *TimePredicateTestSuite) TestTimeP_Negation() {
+	d := 5 * numeric.DurationOf('h')
+	tp := timeP(true, func(n int64) bool {
+		return n > d 
+	})
+	s.False(tp.Negate().IsSatisfiedBy(addTST(-(d+1))))
+	s.True(tp.Negate().IsSatisfiedBy(addTST(-(d-1))))
+}
+
 // addTST => addToStartTime. Saves some typing. Note that v
 // is an int64 duration.
 func addTST(v int64) time.Time {
@@ -64,6 +102,6 @@ func addTST(v int64) time.Time {
 
 func TestTimePredicate(t *testing.T) {
 	s := new(TimePredicateTestSuite)
-	s.Parser = predicateParser(parseTimePredicate)
+	s.Parser = predicate.ToParser(parseTimePredicate)
 	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/meta_test.go b/cmd/internal/find/primary/meta_test.go
index 58586e2..503e0e4 100644
--- a/cmd/internal/find/primary/meta_test.go
+++ b/cmd/internal/find/primary/meta_test.go
@@ -1,43 +1,103 @@
 package primary
 
 import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
 	"testing"
 	"time"
 
 	"github.com/puppetlabs/wash/cmd/internal/find/types"
 	"github.com/puppetlabs/wash/cmd/internal/find/params"
+	"github.com/puppetlabs/wash/cmd/internal/find/parser/parsertest"
 	"github.com/stretchr/testify/suite"
 )
 
 type MetaPrimaryTestSuite struct {
-	suite.Suite
+	parsertest.Suite
+	e types.Entry
 }
 
-func (suite *MetaPrimaryTestSuite) SetupTest() {
+func (s *MetaPrimaryTestSuite) SetupTest() {
 	params.StartTime = time.Now()
 }
 
-func (suite *MetaPrimaryTestSuite) TeardownTest() {
+func (s *MetaPrimaryTestSuite) TeardownTest() {
 	params.StartTime = time.Time{}
 }
 
-func (suite *MetaPrimaryTestSuite) TestMetaPrimaryErrors() {
-	_, _, err := metaPrimary.parse([]string{"-m", "-true"})
-	suite.Regexp("-m: key sequences must begin with a '.'", err)
+func (s *MetaPrimaryTestSuite) TestMetaPrimaryErrors() {
+	s.RunTestCases(
+		s.NPETC("-m", "expected a key sequence", false),
+		s.NPETC("-m foo", "key sequences must begin with a '.'", false),
+		s.NPETC("-m .", "expected a key sequence after '.'", false),
+		s.NPETC("-m .[", "expected a key sequence after '.'", false),
+		s.NPETC("-m .key", "expected a predicate expression", false),
+		s.NPETC("-m .key +{", "expected.*closing.*}", false),
+		s.NPETC("-m .key]", `expected an opening '\['`, false),
+		s.NPETC("-m .key[", `expected a closing '\]'`, false),
+	)
+}
 
-	_, _, err = metaPrimary.parse([]string{"-m", "."})
-	suite.Regexp("-m: expected a key sequence after '.'", err)
+func (s *MetaPrimaryTestSuite) TestMetaPrimaryValidInputTruePredicates() {
+	s.RunTestCases(
+		s.NPTC("-m .architecture x86_64 -primary", "-primary", s.e),
+		s.NPTC("-m .blockDeviceMappings[?] .deviceName /dev/sda1 -primary", "-primary", s.e),
+		s.NPTC("-m .cpuOptions.coreCount 4 -primary", "-primary", s.e),
+		s.NPTC("-m .tags[?] .key termination_date -a .value +1h -primary", "-primary", s.e),
+		s.NPTC("-m .tags[?] .key foo -o .key department -primary", "-primary", s.e),
+		s.NPTC("-m .elasticGpuAssociations -null -primary", "-primary", s.e),
+		s.NPTC("-m .networkInterfaces[?] .association.ipOwnerID amazon -a .privateIpAddresses[?] .association.ipOwnerID amazon -primary", "-primary", s.e),
+	)
 }
 
-func (suite *MetaPrimaryTestSuite) TestMetaPrimaryValidInput() {
-	p, tokens, err := metaPrimary.parse([]string{"-m", "-empty", "-size"})
-	if suite.NoError(err) {
-		suite.Equal([]string{"-size"}, tokens)
-		e := types.Entry{}
-		suite.True(p(e))
-	}
+func (s *MetaPrimaryTestSuite) TestMetaPrimaryValidInputFalsePredicates() {
+	s.RunTestCases(
+		// Should fail b/c arch key does not exist
+		s.NPNTC("-m .arch x86_64 -primary", "-primary", s.e),
+		// Should fail b/c architecture is a string, not a number
+		s.NPNTC("-m .architecture +10 -primary", "-primary", s.e),
+		// Should fail b/c the termination_date tag's value is in the past while
+		// +{1h} queries the future. 
+		s.NPNTC("-m .tags[?] .key termination_date -a .value +{1h} -primary", "-primary", s.e),
+		// Should fail b/c the tags array has elements whose ".key" value is _not_ termination_date.
+		// Informally, this means that this EC2 instance has more than one tag.
+		s.NPNTC("-m .tags[*] .key termination_date -primary", "-primary", s.e),
+		// Should fail b/c architecture cannot be both a number and a string
+		s.NPNTC("-m .architecture +10 -a x86_64 -primary", "-primary", s.e),
+	)
+}
+
+func (s *MetaPrimaryTestSuite) TestMetaPrimaryValidInputNegation() {
+	s.RunTestCases(
+		s.NPNTC("-m .architecture ! x86_64 -primary", "-primary", s.e),
+		s.NPNTC("-m .blockDeviceMappings[?] ! .deviceName /dev/sda1 -primary", "-primary", s.e),
+		s.NPNTC("-m .cpuOptions.coreCount ! 4 -primary", "-primary", s.e),
+		// De'Morgan's Law: !(p1(a) && p2(a)) == ! p1(a) || ! p2(a). Since there's more than one
+		// tag (e.g. "department"), the negation of this predicate will evaluate to true.
+		s.NPTC("-m .tags[?] ! ( .key termination_date -a .value +1h ) -primary", "-primary", s.e),
+		// De'Morgan's Law: !(p1(a) || p2(a)) == !p1(a) && !p2(a). Substituting, this translates to
+		// "at least one tag that does _not_ have the "key" key set to 'foo' AND 'department'". Since
+		// we have a tag with "key" set to "termination_date", and since "termination_date" is not "foo"
+		// and "department", this predicate evaluates to true.
+		s.NPTC("-m .tags[?] ! ( .key foo -o .key department ) -primary", "-primary", s.e),
+		s.NPNTC("-m .elasticGpuAssociations ! -null -primary", "-primary", s.e),
+		// There's only one network interface, so the negation here evaluates to false.
+		s.NPNTC("-m .networkInterfaces[?] ! ( .association.ipOwnerID amazon -a .privateIpAddresses[?] .association.ipOwnerID amazon ) -primary", "-primary", s.e),
+	)
 }
 
 func TestMetaPrimary(t *testing.T) {
-	suite.Run(t, new(MetaPrimaryTestSuite))
+	s := new(MetaPrimaryTestSuite)
+	rawMeta, err := ioutil.ReadFile("testdata/metadata.json")
+	if err != nil {
+		t.Fatal(fmt.Sprintf("Failed to read testdata/metadata.json"))
+	}
+	var m map[string]interface{}
+	if err := json.Unmarshal(rawMeta, &m); err != nil {
+		t.Fatal(fmt.Sprintf("Failed to unmarshal testdata/metadata.json: %v", err))
+	}
+	s.e.Attributes.SetMeta(m)
+	s.Parser = metaPrimary
+	suite.Run(t, s)
 }
diff --git a/cmd/internal/find/primary/testdata/metadata.json b/cmd/internal/find/primary/testdata/metadata.json
new file mode 100644
index 0000000..1441251
--- /dev/null
+++ b/cmd/internal/find/primary/testdata/metadata.json
@@ -0,0 +1,150 @@
+{
+    "AmiLaunchIndex": 0,
+    "Architecture": "x86_64",
+    "BlockDeviceMappings": [
+      {
+        "DeviceName": "/dev/sda1",
+        "Ebs": {
+          "AttachTime": "2018-08-07T13:55:25Z",
+          "DeleteOnTermination": true,
+          "Status": "attached",
+          "VolumeId": "vol-0f6a9b57391d099bb"
+        }
+      }
+    ],
+    "CapacityReservationId": null,
+    "CapacityReservationSpecification": {
+      "CapacityReservationPreference": "open",
+      "CapacityReservationTarget": null
+    },
+    "ClientToken": "",
+    "CpuOptions": {
+      "CoreCount": 4,
+      "ThreadsPerCore": 1
+    },
+    "CreationTime": "2018-08-07T13:55:25Z",
+    "EbsOptimized": false,
+    "ElasticGpuAssociations": null,
+    "ElasticInferenceAcceleratorAssociations": null,
+    "EnaSupport": true,
+    "HibernationOptions": {
+      "Configured": false
+    },
+    "Hypervisor": "xen",
+    "IamInstanceProfile": null,
+    "ImageId": "ami-0ead4c08015751a37",
+    "InstanceId": "i-06b4f0f14864915e5",
+    "InstanceLifecycle": null,
+    "InstanceType": "t2.xlarge",
+    "KernelId": null,
+    "KeyName": "mike.smith.SE",
+    "LastModifiedTime": "2018-10-01T17:37:05Z",
+    "LaunchTime": "2018-10-01T17:37:05Z",
+    "Licenses": null,
+    "Monitoring": {
+      "State": "disabled"
+    },
+    "NetworkInterfaces": [
+      {
+        "Association": {
+          "IpOwnerId": "amazon",
+          "PublicDnsName": "ec2-18-236-186-96.us-west-2.compute.amazonaws.com",
+          "PublicIp": "18.236.186.96"
+        },
+        "Attachment": {
+          "AttachTime": "2018-08-07T13:55:24Z",
+          "AttachmentId": "eni-attach-00a1ac6ea935316a6",
+          "DeleteOnTermination": true,
+          "DeviceIndex": 0,
+          "Status": "attached"
+        },
+        "Description": "Primary network interface",
+        "Groups": [
+          {
+            "GroupId": "sg-0a6a56ccb175928e7",
+            "GroupName": "launch-wizard-102"
+          }
+        ],
+        "Ipv6Addresses": null,
+        "MacAddress": "02:a8:74:70:c4:2e",
+        "NetworkInterfaceId": "eni-04c6eeea9efa025be",
+        "OwnerId": "482693910459",
+        "PrivateDnsName": "ip-10-0-124-233.us-west-2.compute.internal",
+        "PrivateIpAddress": "10.0.124.233",
+        "PrivateIpAddresses": [
+          {
+            "Association": {
+              "IpOwnerId": "amazon",
+              "PublicDnsName": "ec2-18-236-186-96.us-west-2.compute.amazonaws.com",
+              "PublicIp": "18.236.186.96"
+            },
+            "Primary": true,
+            "PrivateDnsName": "ip-10-0-124-233.us-west-2.compute.internal",
+            "PrivateIpAddress": "10.0.124.233"
+          }
+        ],
+        "SourceDestCheck": true,
+        "Status": "in-use",
+        "SubnetId": "subnet-05e99aa622e12192c",
+        "VpcId": "vpc-0eb70f7f626d3db83"
+      }
+    ],
+    "Placement": {
+      "Affinity": null,
+      "AvailabilityZone": "us-west-2a",
+      "GroupName": "",
+      "HostId": null,
+      "PartitionNumber": null,
+      "SpreadDomain": null,
+      "Tenancy": "default"
+    },
+    "Platform": "windows",
+    "PrivateDnsName": "ip-10-0-124-233.us-west-2.compute.internal",
+    "PrivateIpAddress": "10.0.124.233",
+    "ProductCodes": null,
+    "PublicDnsName": "ec2-18-236-186-96.us-west-2.compute.amazonaws.com",
+    "PublicIpAddress": "18.236.186.96",
+    "RamdiskId": null,
+    "RootDeviceName": "/dev/sda1",
+    "RootDeviceType": "ebs",
+    "SecurityGroups": [
+      {
+        "GroupId": "sg-0a6a56ccb175928e7",
+        "GroupName": "launch-wizard-102"
+      }
+    ],
+    "SourceDestCheck": true,
+    "SpotInstanceRequestId": null,
+    "SriovNetSupport": null,
+    "State": {
+      "Code": 16,
+      "Name": "running"
+    },
+    "StateReason": null,
+    "StateTransitionReason": "",
+    "SubnetId": "subnet-05e99aa622e12192c",
+    "Tags": [
+      {
+        "Key": "department",
+        "Value": "SE"
+      },
+      {
+        "Key": "project",
+        "Value": "SE Demo"
+      },
+      {
+        "Key": "created_by",
+        "Value": "Mike Smith"
+      },
+      {
+        "Key": "lifetime",
+        "Value": "52w"
+      },
+      {
+        "Key": "termination_date",
+        "Value": "2017-08-06T13:55:25.680464+00:00"
+      }
+    ],
+    "VirtualizationType": "hvm",
+    "VpcId": "vpc-0eb70f7f626d3db83"
+  }
\ No newline at end of file
diff --git a/volume/fs_test.go b/volume/fs_test.go
index bccd631..257ae5f 100644
--- a/volume/fs_test.go
+++ b/volume/fs_test.go
@@ -52,7 +52,7 @@ func createExec() *mockExecutor {
 	// Used when recording activity.
 	exec.SetTestID("/instance")
 	cmd := StatCmd("/var/log")
-	exec.On("Exec", mock.Anything, cmd[0], cmd[1:], plugin.ExecOptions{Elevate: true}).Return(createResult(varLogFixture), nil)
+	exec.On("Exec", mock.Anything, cmd[0], cmd[1:], plugin.ExecOptions{}).Return(createResult(varLogFixture), nil)
 	return exec
 }
 
@@ -125,7 +125,7 @@ func (suite *fsTestSuite) TestFSRead() {
 	suite.Equal("a file", plugin.Name(entry))
 
 	execResult := createResult("hello")
-	exec.On("Exec", mock.Anything, "cat", []string{"/var/log/path1/a file"}, plugin.ExecOptions{Elevate: true}).Return(execResult, nil)
+	exec.On("Exec", mock.Anything, "cat", []string{"/var/log/path1/a file"}, plugin.ExecOptions{}).Return(execResult, nil)
 	rdr, err := entry.(plugin.Readable).Open(context.Background())
 	suite.NoError(err)
 	suite.Equal(int64(5), rdr.Size())